feat(reporting): add BU Ownership column and per-column Excel-style filters
- buOwnership field extracted from assetCustomAttributes['1550_host_1'][0] and stored in SQLite cache; badge-styled cell (sky=STEAM, amber=ACCESS-ENG) - All columns except Notes get a funnel filter button in the header - FilterDropdown uses ReactDOM.createPortal + fixed positioning to escape overflowX:auto clipping; shows unique value checkboxes with search input, Select All, Clear, and a selected/total count footer - Severity filter groups by vrrGroup label (CRITICAL/HIGH) not numeric value - columnFilters state gates a useMemo filtered array before sorting - Active filter count shown in panel header with amber badge; Clear Filters button appears in the toolbar when any filters are active - Empty Set filter (Clear All) hides all rows, consistent with Excel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff } from 'lucide-react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v1';
|
||||
@@ -8,15 +9,16 @@ const STORAGE_KEY = 'steam_findings_columns_v1';
|
||||
// Column definitions — source of truth for labels, sort behaviour, rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
const COLUMN_DEFS = {
|
||||
severity: { label: 'Severity', sortable: true },
|
||||
title: { label: 'Title', sortable: true },
|
||||
hostName: { label: 'Host', sortable: true },
|
||||
ipAddress: { label: 'IP Address', sortable: true },
|
||||
dns: { label: 'DNS', sortable: true },
|
||||
dueDate: { label: 'Due Date', sortable: true },
|
||||
slaStatus: { label: 'SLA', sortable: true },
|
||||
lastFoundOn: { label: 'Last Found', sortable: true },
|
||||
note: { label: 'Notes', sortable: false },
|
||||
severity: { label: 'Severity', sortable: true, filterable: true },
|
||||
title: { label: 'Title', sortable: true, filterable: true },
|
||||
hostName: { label: 'Host', sortable: true, filterable: true },
|
||||
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
||||
dns: { label: 'DNS', sortable: true, filterable: true },
|
||||
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
||||
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
||||
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
||||
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
||||
note: { label: 'Notes', sortable: false, filterable: false },
|
||||
};
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
@@ -27,6 +29,7 @@ const DEFAULT_COLUMN_ORDER = [
|
||||
{ key: 'dns', visible: true },
|
||||
{ key: 'dueDate', visible: true },
|
||||
{ key: 'slaStatus', visible: true },
|
||||
{ key: 'buOwnership', visible: true },
|
||||
{ key: 'lastFoundOn', visible: true },
|
||||
{ key: 'note', visible: true },
|
||||
];
|
||||
@@ -38,9 +41,8 @@ function loadColumnOrder() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Keep saved order/visibility; add any new default columns at the end
|
||||
const savedKeys = new Set(saved.map((c) => c.key));
|
||||
const merged = saved.filter((c) => COLUMN_DEFS[c.key]); // drop removed cols
|
||||
const merged = saved.filter((c) => COLUMN_DEFS[c.key]);
|
||||
DEFAULT_COLUMN_ORDER.forEach((d) => {
|
||||
if (!savedKeys.has(d.key)) merged.push({ ...d });
|
||||
});
|
||||
@@ -66,12 +68,21 @@ function getVal(finding, key) {
|
||||
case 'dns': return finding.dns ?? '';
|
||||
case 'dueDate': return finding.dueDate ?? '';
|
||||
case 'slaStatus': return finding.slaStatus ?? '';
|
||||
case 'buOwnership': return finding.buOwnership ?? '';
|
||||
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
||||
case 'note': return finding.note ?? '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter accessor — severity filters by vrrGroup label, not numeric value
|
||||
// ---------------------------------------------------------------------------
|
||||
function getFilterVal(finding, key) {
|
||||
if (key === 'severity') return finding.vrrGroup || '';
|
||||
return String(getVal(finding, key) ?? '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Style helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -98,8 +109,8 @@ function dueDateColor(dueDate) {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays < 0) return '#EF4444'; // overdue
|
||||
if (diffDays <= 30) return '#F59E0B'; // due soon
|
||||
if (diffDays < 0) return '#EF4444';
|
||||
if (diffDays <= 30) return '#F59E0B';
|
||||
return '#94A3B8';
|
||||
}
|
||||
|
||||
@@ -170,27 +181,23 @@ function NoteCell({ findingId, initialNote }) {
|
||||
// ColumnManager — popover with drag-to-reorder and show/hide toggles
|
||||
// ---------------------------------------------------------------------------
|
||||
function ColumnManager({ columnOrder, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState(null);
|
||||
const [overIdx, setOverIdx] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState(null);
|
||||
const [overIdx, setOverIdx] = useState(null);
|
||||
const panelRef = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const toggleVisible = (key) => {
|
||||
const updated = columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c);
|
||||
onChange(updated);
|
||||
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
|
||||
};
|
||||
|
||||
const handleDragStart = (idx) => setDragIdx(idx);
|
||||
@@ -245,7 +252,7 @@ function ColumnManager({ columnOrder, onChange }) {
|
||||
Drag to reorder · click to toggle
|
||||
</div>
|
||||
{columnOrder.map((col, idx) => {
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const isDragging = dragIdx === idx;
|
||||
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
|
||||
return (
|
||||
@@ -258,9 +265,7 @@ function ColumnManager({ columnOrder, onChange }) {
|
||||
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.4rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
cursor: 'grab',
|
||||
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
|
||||
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
|
||||
@@ -275,10 +280,7 @@ function ColumnManager({ columnOrder, onChange }) {
|
||||
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
|
||||
>
|
||||
{col.visible
|
||||
? <Eye style={{ width: '14px', height: '14px' }} />
|
||||
: <EyeOff style={{ width: '14px', height: '14px' }} />
|
||||
}
|
||||
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -289,6 +291,149 @@ function ColumnManager({ columnOrder, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FilterDropdown — portal-based so it escapes overflow:auto clipping
|
||||
// ---------------------------------------------------------------------------
|
||||
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
const [search, setSearch] = useState('');
|
||||
const panelRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Compute fixed position from anchor button's viewport rect
|
||||
useEffect(() => {
|
||||
if (!anchorEl) return;
|
||||
const r = anchorEl.getBoundingClientRect();
|
||||
setPos({ top: r.bottom + 4, left: r.left });
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [anchorEl]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
||||
!(anchorEl && anchorEl.contains(e.target))) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [anchorEl, onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
// Unique values from the full (unfiltered) findings list
|
||||
const allValues = useMemo(() => {
|
||||
const vals = new Set();
|
||||
findings.forEach((f) => {
|
||||
const v = getFilterVal(f, colKey).trim();
|
||||
if (v) vals.add(v);
|
||||
});
|
||||
return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
}, [findings, colKey]);
|
||||
|
||||
const displayed = search.trim()
|
||||
? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
|
||||
: allValues;
|
||||
|
||||
const isChecked = (val) => !activeFilter || activeFilter.has(val);
|
||||
const activeCount = activeFilter ? activeFilter.size : allValues.length;
|
||||
|
||||
const toggle = (val) => {
|
||||
let next;
|
||||
if (!activeFilter) {
|
||||
next = new Set(allValues);
|
||||
next.delete(val);
|
||||
} else {
|
||||
next = new Set(activeFilter);
|
||||
if (next.has(val)) next.delete(val); else next.add(val);
|
||||
}
|
||||
// If all values selected again, remove the filter entirely
|
||||
onFilterChange(next.size >= allValues.length ? null : next);
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left,
|
||||
width: '220px', zIndex: 9999,
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
border: '1px solid rgba(14,165,233,0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
|
||||
padding: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search values…"
|
||||
style={{
|
||||
width: '100%', marginBottom: '0.375rem',
|
||||
background: 'rgba(14,165,233,0.05)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
|
||||
color: '#CBD5E1', fontSize: '0.72rem',
|
||||
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Select All / Clear */}
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<button
|
||||
onClick={() => onFilterChange(null)}
|
||||
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onFilterChange(new Set())}
|
||||
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Value checkboxes */}
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{displayed.length === 0 ? (
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
|
||||
) : displayed.map((val) => (
|
||||
<label
|
||||
key={val}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked(val)}
|
||||
onChange={() => toggle(val)}
|
||||
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status footer */}
|
||||
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
|
||||
{activeCount} / {allValues.length} selected
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -342,6 +487,31 @@ function TableCell({ colKey, finding }) {
|
||||
{finding.slaStatus || '—'}
|
||||
</td>
|
||||
);
|
||||
case 'buOwnership': {
|
||||
const bu = finding.buOwnership || '';
|
||||
const isSteam = bu.toUpperCase().includes('STEAM');
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
{bu ? (
|
||||
<span
|
||||
title={bu}
|
||||
style={{
|
||||
display: 'inline-block', padding: '0.15rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
|
||||
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||||
color: isSteam ? '#0EA5E9' : '#F59E0B',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{bu.replace('NTS-AEO-', '')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#475569' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'lastFoundOn':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
@@ -363,17 +533,19 @@ function TableCell({ colKey, finding }) {
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ReportingPage() {
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
const [syncStatus, setSyncStatus] = useState(null);
|
||||
const [syncError, setSyncError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
const [syncStatus, setSyncStatus] = useState(null);
|
||||
const [syncError, setSyncError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||
const [columnFilters, setColumnFilters] = useState({});
|
||||
const [openFilter, setOpenFilter] = useState(null);
|
||||
const filterBtnRefs = useRef({});
|
||||
|
||||
// Persist column changes
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
saveColumnOrder(newOrder);
|
||||
@@ -390,7 +562,7 @@ export default function ReportingPage() {
|
||||
const fetchFindings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) applyState(data);
|
||||
} catch (e) {
|
||||
@@ -403,10 +575,7 @@ export default function ReportingPage() {
|
||||
const syncFindings = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok) applyState(data);
|
||||
} catch (e) {
|
||||
@@ -418,11 +587,35 @@ export default function ReportingPage() {
|
||||
|
||||
useEffect(() => { fetchFindings(); }, []); // eslint-disable-line
|
||||
|
||||
// Set/clear a single column filter
|
||||
const setColFilter = useCallback((colKey, vals) => {
|
||||
setColumnFilters((prev) => {
|
||||
if (!vals) {
|
||||
const next = { ...prev };
|
||||
delete next[colKey];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [colKey]: vals };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Apply all active column filters to produce the visible row set
|
||||
const filtered = useMemo(() => {
|
||||
const active = Object.entries(columnFilters);
|
||||
if (active.length === 0) return findings;
|
||||
return findings.filter((f) =>
|
||||
active.every(([key, vals]) => {
|
||||
if (!vals || vals.size === 0) return false;
|
||||
return vals.has(getFilterVal(f, key).trim());
|
||||
})
|
||||
);
|
||||
}, [findings, columnFilters]);
|
||||
|
||||
// Visible columns in current order
|
||||
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
||||
|
||||
// Sorted findings
|
||||
const sorted = [...findings].sort((a, b) => {
|
||||
// Sort filtered results
|
||||
const sorted = useMemo(() => [...filtered].sort((a, b) => {
|
||||
const av = getVal(a, sort.field);
|
||||
const bv = getVal(b, sort.field);
|
||||
let cmp = 0;
|
||||
@@ -432,7 +625,7 @@ export default function ReportingPage() {
|
||||
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
|
||||
}
|
||||
return sort.dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}), [filtered, sort]);
|
||||
|
||||
const toggleSort = (key) => {
|
||||
setSort((prev) =>
|
||||
@@ -442,6 +635,8 @@ export default function ReportingPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.keys(columnFilters).length;
|
||||
|
||||
const syncedDisplay = syncedAt
|
||||
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||
: 'Never synced';
|
||||
@@ -496,13 +691,38 @@ export default function ReportingPage() {
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||
{syncedDisplay}
|
||||
{syncStatus === 'success' && total !== null && (
|
||||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>{total} findings</span>
|
||||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
|
||||
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
|
||||
{activeFilterCount > 0 && (
|
||||
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
|
||||
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={() => setColumnFilters({})}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: 'rgba(245,158,11,0.08)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F59E0B', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
<Filter style={{ width: '11px', height: '11px' }} />
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
||||
<button
|
||||
onClick={syncFindings}
|
||||
@@ -549,27 +769,47 @@ export default function ReportingPage() {
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
||||
{visibleCols.map((col) => {
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const active = sort.field === col.key;
|
||||
const def = COLUMN_DEFS[col.key];
|
||||
const active = sort.field === col.key;
|
||||
const isFiltered = !!columnFilters[col.key];
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
textAlign: 'left',
|
||||
padding: '0.5rem 0.75rem', textAlign: 'left',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
color: active ? '#0EA5E9' : '#64748B',
|
||||
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: def?.sortable ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
background: 'rgba(15,26,46,0.6)'
|
||||
background: 'rgba(15,26,46,0.6)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
{def?.label || col.key}
|
||||
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
||||
{def?.filterable && (
|
||||
<button
|
||||
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilter(openFilter === col.key ? null : col.key);
|
||||
}}
|
||||
title={`Filter ${def.label}`}
|
||||
style={{
|
||||
background: 'none', border: 'none',
|
||||
cursor: 'pointer', padding: '1px 1px 1px 3px',
|
||||
color: isFiltered ? '#F59E0B' : '#334155',
|
||||
lineHeight: 1, flexShrink: 0,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Filter style={{ width: '10px', height: '10px' }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
@@ -595,7 +835,7 @@ export default function ReportingPage() {
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
No findings found
|
||||
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -604,6 +844,18 @@ export default function ReportingPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter dropdown — rendered via portal at document.body */}
|
||||
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
|
||||
<FilterDropdown
|
||||
anchorEl={filterBtnRefs.current[openFilter]}
|
||||
colKey={openFilter}
|
||||
findings={findings}
|
||||
activeFilter={columnFilters[openFilter] || null}
|
||||
onFilterChange={(vals) => setColFilter(openFilter, vals)}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user