feat(reporting): Action Coverage chart + Archer Exception linking
Replace FP# Workflow chart with a 3-segment Action Coverage donut: - FP Request — finding has an Ivanti FP# workflow - Archer Exception — note matches EXC-\d+ pattern - Pending — no action taken yet Clicking a segment filters the findings table to that category with a colored badge in the action bar (click again or × to clear). Home page: each Archer ticket now has a filter icon button that navigates directly to the Reporting page pre-filtered to findings whose notes reference that EXC number. The EXC badge appears in the table action bar with a one-click clear. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -179,6 +179,7 @@ export default function App() {
|
|||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const [calendarFilter, setCalendarFilter] = useState(null);
|
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||||
|
const [reportingExcFilter, setReportingExcFilter] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
@@ -963,8 +964,8 @@ export default function App() {
|
|||||||
onClose={() => setNavOpen(false)}
|
onClose={() => setNavOpen(false)}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={(page) => {
|
onNavigate={(page) => {
|
||||||
// Clear calendar filter when navigating directly via the nav drawer
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
if (page === 'reporting') setCalendarFilter(null);
|
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1041,7 +1042,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
|
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
|
||||||
@@ -2332,16 +2333,23 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{ticket.exc_number}
|
{ticket.exc_number}
|
||||||
</a>
|
</a>
|
||||||
{canWrite() && (
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
|
||||||
|
title="View findings referencing this ticket"
|
||||||
|
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (<>
|
||||||
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||||
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||||
|
|||||||
@@ -117,6 +117,17 @@ function getExportVal(finding, key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action coverage classification — used by chart and filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const EXC_PATTERN = /EXC-\d+/i;
|
||||||
|
|
||||||
|
function classifyFinding(finding) {
|
||||||
|
if (finding.workflow != null) return 'fp';
|
||||||
|
if (EXC_PATTERN.test(finding.note || '')) return 'archer';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Style helpers
|
// Style helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -265,19 +276,15 @@ function StatusDonut({ open, closed, loading }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SVG Donut Chart — FP# workflow state distribution
|
// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const WF_STATE_DEFS = [
|
const ACTION_DEFS = [
|
||||||
{ key: 'expired', label: 'Expired', color: '#EF4444' },
|
{ key: 'fp', label: 'FP Request', color: '#0EA5E9' },
|
||||||
{ key: 'rejected', label: 'Rejected', color: '#F87171' },
|
{ key: 'archer', label: 'Archer Exception', color: '#F59E0B' },
|
||||||
{ key: 'reworked', label: 'Reworked', color: '#F59E0B' },
|
{ key: 'pending', label: 'Pending', color: '#EF4444' },
|
||||||
{ key: 'actionable', label: 'Actionable', color: '#FCD34D' },
|
|
||||||
{ key: 'requested', label: 'Requested', color: '#0EA5E9' },
|
|
||||||
{ key: 'approved', label: 'Approved', color: '#10B981' },
|
|
||||||
{ key: 'none', label: 'No FP#', color: '#334155' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function WorkflowDonut({ findings }) {
|
function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||||
const SIZE = 180;
|
const SIZE = 180;
|
||||||
const CX = SIZE / 2;
|
const CX = SIZE / 2;
|
||||||
const CY = SIZE / 2;
|
const CY = SIZE / 2;
|
||||||
@@ -285,16 +292,12 @@ function WorkflowDonut({ findings }) {
|
|||||||
const INNER = 48;
|
const INNER = 48;
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const map = Object.fromEntries(WF_STATE_DEFS.map((d) => [d.key, 0]));
|
const map = { fp: 0, archer: 0, pending: 0 };
|
||||||
findings.forEach((f) => {
|
findings.forEach((f) => { map[classifyFinding(f)]++; });
|
||||||
const state = (f.workflow?.state || '').toLowerCase();
|
|
||||||
if (state && state in map) map[state]++;
|
|
||||||
else map.none++;
|
|
||||||
});
|
|
||||||
return map;
|
return map;
|
||||||
}, [findings]);
|
}, [findings]);
|
||||||
|
|
||||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
const total = findings.length;
|
||||||
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -305,29 +308,35 @@ function WorkflowDonut({ findings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
const segments = WF_STATE_DEFS
|
const segments = ACTION_DEFS.map((def) => {
|
||||||
.map((def) => {
|
|
||||||
const count = counts[def.key];
|
const count = counts[def.key];
|
||||||
if (!count) return null;
|
|
||||||
const start = cursor;
|
const start = cursor;
|
||||||
const end = cursor + (count / total) * 360;
|
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
|
||||||
cursor = end;
|
if (count > 0) cursor = end;
|
||||||
return { ...def, count, start, end };
|
return { ...def, count, start, end };
|
||||||
})
|
});
|
||||||
.filter(Boolean);
|
|
||||||
|
const hasActive = !!activeSegment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||||
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||||
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||||
{segments.map((seg) => (
|
{segments.filter((s) => s.count > 0).map((seg) => {
|
||||||
|
const isActive = activeSegment === seg.key;
|
||||||
|
return (
|
||||||
<path
|
<path
|
||||||
key={seg.key}
|
key={seg.key}
|
||||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||||
fill={seg.color}
|
fill={seg.color}
|
||||||
opacity={0.88}
|
opacity={hasActive ? (isActive ? 1 : 0.25) : 0.88}
|
||||||
|
stroke={isActive ? 'rgba(255,255,255,0.6)' : 'none'}
|
||||||
|
strokeWidth={isActive ? 2 : 0}
|
||||||
|
style={{ cursor: 'pointer', transition: 'opacity 0.2s' }}
|
||||||
|
onClick={() => onSegmentClick(isActive ? null : seg.key)}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||||
{total.toLocaleString()}
|
{total.toLocaleString()}
|
||||||
</text>
|
</text>
|
||||||
@@ -336,24 +345,40 @@ function WorkflowDonut({ findings }) {
|
|||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend — always shows all 3 categories */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
{segments.map((seg) => (
|
{segments.map((seg) => {
|
||||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
const isActive = activeSegment === seg.key;
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
const dimmed = hasActive && !isActive;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seg.key}
|
||||||
|
onClick={() => onSegmentClick(isActive ? null : seg.key)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0, outline: isActive ? `2px solid ${seg.color}` : 'none', outlineOffset: '1px' }} />
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
{seg.label}
|
{seg.label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||||
{seg.count}
|
{seg.count}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||||
({((seg.count / total) * 100).toFixed(0)}%)
|
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
{hasActive && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSegmentClick(null)}
|
||||||
|
style={{ marginTop: '0.25rem', background: 'none', border: 'none', fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', cursor: 'pointer', textAlign: 'left', padding: 0, textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -835,7 +860,7 @@ function TableCell({ colKey, finding }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage
|
// Main ReportingPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ReportingPage({ filterDate }) {
|
export default function ReportingPage({ filterDate, filterEXC }) {
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
const [total, setTotal] = useState(null);
|
const [total, setTotal] = useState(null);
|
||||||
const [syncedAt, setSyncedAt] = useState(null);
|
const [syncedAt, setSyncedAt] = useState(null);
|
||||||
@@ -852,6 +877,8 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
);
|
);
|
||||||
const [openFilter, setOpenFilter] = useState(null);
|
const [openFilter, setOpenFilter] = useState(null);
|
||||||
const filterBtnRefs = useRef({});
|
const filterBtnRefs = useRef({});
|
||||||
|
const [actionFilter, setActionFilter] = useState(null);
|
||||||
|
const [excFilter, setExcFilter] = useState(filterEXC || null);
|
||||||
|
|
||||||
const updateColumns = useCallback((newOrder) => {
|
const updateColumns = useCallback((newOrder) => {
|
||||||
setColumnOrder(newOrder);
|
setColumnOrder(newOrder);
|
||||||
@@ -925,22 +952,38 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply all active column filters to produce the visible row set
|
// Apply all active filters to produce the visible row set
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
|
let result = findings;
|
||||||
|
|
||||||
|
// Column filters
|
||||||
const active = Object.entries(columnFilters);
|
const active = Object.entries(columnFilters);
|
||||||
if (active.length === 0) return findings;
|
if (active.length > 0) {
|
||||||
return findings.filter((f) =>
|
result = result.filter((f) =>
|
||||||
active.every(([key, vals]) => {
|
active.every(([key, vals]) => {
|
||||||
if (!vals || vals.size === 0) return false;
|
if (!vals || vals.size === 0) return false;
|
||||||
const def = COLUMN_DEFS[key];
|
const def = COLUMN_DEFS[key];
|
||||||
if (def?.multiValue) {
|
if (def?.multiValue) {
|
||||||
// Row matches if ANY of its values is in the selected set
|
|
||||||
return (f[key] || []).some((v) => vals.has(String(v).trim()));
|
return (f[key] || []).some((v) => vals.has(String(v).trim()));
|
||||||
}
|
}
|
||||||
return vals.has(getFilterVal(f, key).trim());
|
return vals.has(getFilterVal(f, key).trim());
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [findings, columnFilters]);
|
}
|
||||||
|
|
||||||
|
// Action coverage filter (chart segment click)
|
||||||
|
if (actionFilter) {
|
||||||
|
result = result.filter((f) => classifyFinding(f) === actionFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXC filter (navigated from home page Archer ticket)
|
||||||
|
if (excFilter) {
|
||||||
|
const upper = excFilter.toUpperCase();
|
||||||
|
result = result.filter((f) => (f.note || '').toUpperCase().includes(upper));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [findings, columnFilters, actionFilter, excFilter]);
|
||||||
|
|
||||||
// Visible columns in current order
|
// Visible columns in current order
|
||||||
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
||||||
@@ -966,7 +1009,7 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeFilterCount = Object.keys(columnFilters).length;
|
const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0);
|
||||||
|
|
||||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
const exportBtnRef = useRef(null);
|
const exportBtnRef = useRef(null);
|
||||||
@@ -1073,12 +1116,20 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||||
|
|
||||||
{/* FP# Workflow state donut */}
|
{/* Action Coverage donut */}
|
||||||
<div style={{ flex: '0 0 auto' }}>
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
FP# Workflow Status
|
Action Coverage
|
||||||
|
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered</span>}
|
||||||
</div>
|
</div>
|
||||||
<WorkflowDonut findings={findings} />
|
<ActionCoverageDonut
|
||||||
|
findings={findings}
|
||||||
|
activeSegment={actionFilter}
|
||||||
|
onSegmentClick={(key) => {
|
||||||
|
setExcFilter(null);
|
||||||
|
setActionFilter(key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1117,7 +1168,46 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
{activeFilterCount > 0 && (
|
{/* EXC filter badge (from home page navigation) */}
|
||||||
|
{excFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExcFilter(null)}
|
||||||
|
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',
|
||||||
|
letterSpacing: '0.05em'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Filter style={{ width: '11px', height: '11px' }} />
|
||||||
|
{excFilter} ×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Action coverage filter badge (from chart click) */}
|
||||||
|
{actionFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActionFilter(null)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: actionFilter === 'fp' ? 'rgba(14,165,233,0.08)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)',
|
||||||
|
border: `1px solid ${actionFilter === 'fp' ? 'rgba(14,165,233,0.3)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: actionFilter === 'fp' ? '#0EA5E9' : actionFilter === 'archer' ? '#F59E0B' : '#EF4444',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
letterSpacing: '0.05em'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Filter style={{ width: '11px', height: '11px' }} />
|
||||||
|
{ACTION_DEFS.find(d => d.key === actionFilter)?.label} ×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Object.keys(columnFilters).length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setColumnFilters({})}
|
onClick={() => setColumnFilters({})}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user