feat: replace binary scope toggle with multi-select BU picker

- Add IVANTI_BU_FILTER to .env with all four BUs (STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV)
- Rework AdminScopeToggle from binary (My Teams/All) to multi-select dropdown
- Admin can now pick any combination of BUs to view
- Presets: 'All BUs' and 'My Teams' for quick selection
- Individual team checkboxes for custom combinations
- Selection persisted in localStorage as JSON array
- AuthContext updated: adminScope is now an array of selected teams
- getActiveTeamsParam() returns comma-joined selected teams (empty = no filter)
- getAvailableTeams() returns selected teams for compliance selector
This commit is contained in:
Jordan Ramos
2026-05-05 11:31:15 -06:00
parent 9b8ae6cd79
commit df3173a720
2 changed files with 206 additions and 57 deletions

View File

@@ -1,59 +1,164 @@
// AdminScopeToggle.js
// Two-state toggle for Admin users: "My Teams" vs "All BUs"
// Controls whether data on Reporting, Compliance, and Exports pages
// is scoped to the admin's assigned teams or shows everything.
// Multi-select BU scope picker for Admin users.
// Allows selecting any combination of teams to filter Reporting/Compliance/Exports.
// "My Teams" preset selects the admin's assigned bu_teams.
// "All BUs" preset selects everything.
// Custom selections are persisted in localStorage.
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
function AdminScopeToggle() {
const { isAdmin, adminScope, toggleAdminScope, hasTeams } = useAuth();
const { isAdmin, user, adminScope, setAdminScopeTeams, KNOWN_TEAMS, hasTeams } = useAuth();
const [open, setOpen] = useState(false);
const ref = useRef(null);
// Only render for Admin users who have teams assigned
// (if no teams assigned, both modes are identical — no toggle needed)
if (!isAdmin() || !hasTeams()) return null;
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const isAllMode = adminScope === 'all';
// Only render for Admin users
if (!isAdmin()) return null;
const selectedTeams = adminScope || [];
const myTeams = user?.teams || [];
const allSelected = selectedTeams.length === KNOWN_TEAMS.length;
const isMyTeams = myTeams.length > 0 &&
selectedTeams.length === myTeams.length &&
myTeams.every(t => selectedTeams.includes(t));
const toggleTeam = (team) => {
const next = selectedTeams.includes(team)
? selectedTeams.filter(t => t !== team)
: [...selectedTeams, team];
setAdminScopeTeams(next);
};
const selectAll = () => setAdminScopeTeams([...KNOWN_TEAMS]);
const selectMyTeams = () => setAdminScopeTeams([...myTeams]);
// Label for the button
let label;
if (allSelected || selectedTeams.length === 0) {
label = 'All BUs';
} else if (isMyTeams) {
label = 'My Teams';
} else {
label = selectedTeams.join(', ');
}
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
padding: '0.25rem 0.5rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.05)',
border: '1px solid rgba(14, 165, 233, 0.2)',
fontSize: '0.7rem',
fontFamily: 'monospace',
userSelect: 'none',
}}
>
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={toggleAdminScope}
aria-label={`Switch to ${isAllMode ? 'My Teams' : 'All BUs'} view`}
aria-pressed={isAllMode}
onClick={() => setOpen(!open)}
aria-label="Select BU scope"
aria-expanded={open}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.2rem 0.5rem',
borderRadius: '0.25rem',
border: 'none',
cursor: 'pointer',
fontFamily: 'monospace',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.05)',
border: '1px solid rgba(14, 165, 233, 0.2)',
fontSize: '0.68rem',
fontFamily: 'monospace',
fontWeight: '600',
letterSpacing: '0.02em',
transition: 'all 0.15s ease',
background: isAllMode ? 'rgba(139, 92, 246, 0.15)' : 'rgba(14, 165, 233, 0.15)',
color: isAllMode ? '#8B5CF6' : '#0EA5E9',
color: '#0EA5E9',
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{isAllMode ? '⊕ All BUs' : '⊙ My Teams'}
<span style={{ color: '#64748B', fontWeight: '500' }}>Scope:</span>
<span>{label}</span>
<span style={{ fontSize: '0.6rem', color: '#475569' }}></span>
</button>
{open && (
<div style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 100,
minWidth: '180px',
background: 'linear-gradient(135deg, #1E293B 0%, #0F172A 100%)',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '0.5rem',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
padding: '0.5rem 0',
}}>
{/* Presets */}
<div style={{ padding: '0.25rem 0.75rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button
onClick={selectAll}
style={{
display: 'block', width: '100%', textAlign: 'left',
background: allSelected ? 'rgba(139,92,246,0.12)' : 'none',
border: 'none', padding: '0.3rem 0.4rem', borderRadius: '0.25rem',
color: allSelected ? '#A78BFA' : '#94A3B8',
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: 'pointer', marginBottom: '0.2rem',
}}
>
All BUs
</button>
{myTeams.length > 0 && (
<button
onClick={selectMyTeams}
style={{
display: 'block', width: '100%', textAlign: 'left',
background: isMyTeams ? 'rgba(14,165,233,0.12)' : 'none',
border: 'none', padding: '0.3rem 0.4rem', borderRadius: '0.25rem',
color: isMyTeams ? '#0EA5E9' : '#94A3B8',
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
cursor: 'pointer',
}}
>
My Teams ({myTeams.join(', ')})
</button>
)}
</div>
{/* Individual team checkboxes */}
<div style={{ padding: '0.5rem 0.75rem' }}>
{KNOWN_TEAMS.map(team => {
const checked = selectedTeams.includes(team);
return (
<label
key={team}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
padding: '0.3rem 0.25rem', cursor: 'pointer',
borderRadius: '0.2rem',
fontSize: '0.72rem', fontFamily: 'monospace',
color: checked ? '#E2E8F0' : '#64748B',
fontWeight: checked ? '600' : '400',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTeam(team)}
style={{ accentColor: '#0EA5E9', cursor: 'pointer' }}
/>
{team}
</label>
);
})}
</div>
</div>
)}
</div>
);
}