Compare commits
3 Commits
feature/re
...
07894709ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a |
@@ -4,6 +4,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -151,9 +152,25 @@ function initTables(db) {
|
||||
VALUES (1, 0, 0)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
finding_id TEXT NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(finding_id, field)
|
||||
)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||
ON ivanti_finding_notes(finding_id)
|
||||
`, (err) => { if (err) return reject(err); });
|
||||
|
||||
db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||
ON ivanti_finding_overrides(finding_id)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
@@ -395,9 +412,28 @@ function readCounts(db) {
|
||||
});
|
||||
}
|
||||
|
||||
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
||||
function readOverrides(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const map = {};
|
||||
(rows || []).forEach((r) => {
|
||||
if (!map[r.finding_id]) map[r.finding_id] = {};
|
||||
map[r.finding_id][r.field] = r.value;
|
||||
});
|
||||
resolve(map);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readStateWithNotes(db) {
|
||||
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
||||
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
||||
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
||||
state.findings = state.findings.map((f) => ({
|
||||
...f,
|
||||
note: notes[f.id] || '',
|
||||
overrides: overrides[f.id] || {},
|
||||
}));
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -441,6 +477,42 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
const { field, value } = req.body;
|
||||
|
||||
if (!OVERRIDE_ALLOWED.includes(field)) {
|
||||
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
||||
}
|
||||
|
||||
const val = String(value ?? '').trim();
|
||||
|
||||
if (val === '') {
|
||||
// Empty value = clear the override (revert to Ivanti)
|
||||
db.run(
|
||||
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
||||
[findingId, field],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
||||
res.json({ finding_id: findingId, field, value: null });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
db.run(
|
||||
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
||||
[findingId, field, val],
|
||||
(err) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
||||
res.json({ finding_id: findingId, field, value: val });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||
router.put('/:findingId/note', (req, res) => {
|
||||
const { findingId } = req.params;
|
||||
|
||||
@@ -179,6 +179,7 @@ export default function App() {
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||
const [reportingExcFilter, setReportingExcFilter] = useState(null);
|
||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
@@ -963,8 +964,8 @@ export default function App() {
|
||||
onClose={() => setNavOpen(false)}
|
||||
currentPage={currentPage}
|
||||
onNavigate={(page) => {
|
||||
// Clear calendar filter when navigating directly via the nav drawer
|
||||
if (page === 'reporting') setCalendarFilter(null);
|
||||
// Clear contextual filters when navigating directly via the nav drawer
|
||||
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
@@ -1041,7 +1042,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
|
||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||
{currentPage === 'exports' && <ExportsPage />}
|
||||
|
||||
@@ -2332,16 +2333,23 @@ export default function App() {
|
||||
>
|
||||
{ticket.exc_number}
|
||||
</a>
|
||||
{canWrite() && (
|
||||
<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">
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</>)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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, Download } from 'lucide-react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||
@@ -117,6 +118,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -264,6 +276,115 @@ function StatusDonut({ open, closed, loading }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending)
|
||||
// ---------------------------------------------------------------------------
|
||||
const ACTION_DEFS = [
|
||||
{ key: 'fp', label: 'FP Request', color: '#0EA5E9' },
|
||||
{ key: 'archer', label: 'Archer Exception', color: '#F59E0B' },
|
||||
{ key: 'pending', label: 'Pending', color: '#EF4444' },
|
||||
];
|
||||
|
||||
function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||
const SIZE = 180;
|
||||
const CX = SIZE / 2;
|
||||
const CY = SIZE / 2;
|
||||
const OUTER = 72;
|
||||
const INNER = 48;
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map = { fp: 0, archer: 0, pending: 0 };
|
||||
findings.forEach((f) => { map[classifyFinding(f)]++; });
|
||||
return map;
|
||||
}, [findings]);
|
||||
|
||||
const total = findings.length;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const segments = ACTION_DEFS.map((def) => {
|
||||
const count = counts[def.key];
|
||||
const start = cursor;
|
||||
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
|
||||
if (count > 0) cursor = end;
|
||||
return { ...def, count, start, end };
|
||||
});
|
||||
|
||||
const hasActive = !!activeSegment;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<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" />
|
||||
{segments.filter((s) => s.count > 0).map((seg) => {
|
||||
const isActive = activeSegment === seg.key;
|
||||
return (
|
||||
<path
|
||||
key={seg.key}
|
||||
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||
fill={seg.color}
|
||||
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' }}>
|
||||
{total.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
TOTAL
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Legend — always shows all 3 categories */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
{segments.map((seg) => {
|
||||
const isActive = activeSegment === seg.key;
|
||||
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>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon({ colKey, sort }) {
|
||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||
return sort.dir === 'asc'
|
||||
@@ -271,6 +392,119 @@ function SortIcon({ colKey, sort }) {
|
||||
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OverrideCell — inline editable hostname/dns with amber dot when overridden
|
||||
// ---------------------------------------------------------------------------
|
||||
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
|
||||
const effective = initialOverride ?? originalValue ?? '';
|
||||
const [value, setValue] = useState(effective);
|
||||
const [isOverridden, setOverridden] = useState(!!initialOverride);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const lastSaved = useRef(effective);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Sync when the finding updates (e.g. after a full sync)
|
||||
useEffect(() => {
|
||||
const eff = initialOverride ?? originalValue ?? '';
|
||||
setValue(eff);
|
||||
setOverridden(!!initialOverride);
|
||||
lastSaved.current = eff;
|
||||
}, [initialOverride, originalValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) inputRef.current.focus();
|
||||
}, [editing]);
|
||||
|
||||
const persist = useCallback(async (newVal) => {
|
||||
const trimmed = newVal.trim();
|
||||
if (trimmed === lastSaved.current) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ field, value: trimmed }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const cleared = data.value === null;
|
||||
const displayed = cleared ? (originalValue ?? '') : trimmed;
|
||||
setValue(displayed);
|
||||
setOverridden(!cleared);
|
||||
lastSaved.current = displayed;
|
||||
} else {
|
||||
setValue(lastSaved.current); // revert on error
|
||||
}
|
||||
} catch {
|
||||
setValue(lastSaved.current);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [findingId, field, originalValue]);
|
||||
|
||||
const handleBlur = () => { setEditing(false); persist(value); };
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') { e.target.blur(); }
|
||||
if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); }
|
||||
};
|
||||
const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); };
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<td style={{ padding: '0.3rem 0.5rem' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
width: '100%', minWidth: '120px',
|
||||
background: 'rgba(14,165,233,0.08)',
|
||||
border: '1px solid rgba(14,165,233,0.4)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.2rem 0.4rem',
|
||||
color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span
|
||||
onClick={canWrite ? () => setEditing(true) : undefined}
|
||||
title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick to edit` : canWrite ? 'Click to edit' : undefined}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||
color: isOverridden ? '#E2E8F0' : '#94A3B8',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
cursor: canWrite ? 'text' : 'default',
|
||||
}}
|
||||
>
|
||||
{isOverridden && (
|
||||
<span title="Local override active" style={{ width: '5px', height: '5px', borderRadius: '50%', background: '#F59E0B', flexShrink: 0, marginRight: '1px' }} />
|
||||
)}
|
||||
{value || '—'}
|
||||
{saving && <Loader style={{ width: '10px', height: '10px', color: '#475569', animation: 'spin 1s linear infinite', flexShrink: 0 }} />}
|
||||
{isOverridden && canWrite && !saving && (
|
||||
<button
|
||||
onClick={handleRevert}
|
||||
title="Revert to Ivanti value"
|
||||
style={{ background: 'none', border: 'none', padding: '0 1px', cursor: 'pointer', color: '#475569', lineHeight: 1, flexShrink: 0, display: 'inline-flex', alignItems: 'center' }}
|
||||
>
|
||||
<RotateCcw style={{ width: '10px', height: '10px' }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NoteCell — inline editable, saves on blur
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -593,7 +827,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render a single table cell by column key
|
||||
// ---------------------------------------------------------------------------
|
||||
function TableCell({ colKey, finding }) {
|
||||
function TableCell({ colKey, finding, canWrite }) {
|
||||
switch (colKey) {
|
||||
case 'findingId':
|
||||
return (
|
||||
@@ -643,19 +877,30 @@ function TableCell({ colKey, finding }) {
|
||||
);
|
||||
}
|
||||
case 'hostName':
|
||||
return (
|
||||
<OverrideCell
|
||||
findingId={finding.id}
|
||||
field="hostName"
|
||||
originalValue={finding.hostName}
|
||||
initialOverride={finding.overrides?.hostName ?? null}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
case 'ipAddress':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||
{finding[colKey] || '—'}
|
||||
{finding.ipAddress || '—'}
|
||||
</td>
|
||||
);
|
||||
case 'dns':
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
|
||||
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
|
||||
{finding.dns || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<OverrideCell
|
||||
findingId={finding.id}
|
||||
field="dns"
|
||||
originalValue={finding.dns}
|
||||
initialOverride={finding.overrides?.dns ?? null}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
case 'dueDate': {
|
||||
const color = dueDateColor(finding.dueDate);
|
||||
@@ -740,7 +985,8 @@ function TableCell({ colKey, finding }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main ReportingPage
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ReportingPage({ filterDate }) {
|
||||
export default function ReportingPage({ filterDate, filterEXC }) {
|
||||
const { canWrite } = useAuth();
|
||||
const [findings, setFindings] = useState([]);
|
||||
const [total, setTotal] = useState(null);
|
||||
const [syncedAt, setSyncedAt] = useState(null);
|
||||
@@ -757,6 +1003,8 @@ export default function ReportingPage({ filterDate }) {
|
||||
);
|
||||
const [openFilter, setOpenFilter] = useState(null);
|
||||
const filterBtnRefs = useRef({});
|
||||
const [actionFilter, setActionFilter] = useState(null);
|
||||
const [excFilter, setExcFilter] = useState(filterEXC || null);
|
||||
|
||||
const updateColumns = useCallback((newOrder) => {
|
||||
setColumnOrder(newOrder);
|
||||
@@ -830,22 +1078,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(() => {
|
||||
let result = findings;
|
||||
|
||||
// Column filters
|
||||
const active = Object.entries(columnFilters);
|
||||
if (active.length === 0) return findings;
|
||||
return findings.filter((f) =>
|
||||
if (active.length > 0) {
|
||||
result = result.filter((f) =>
|
||||
active.every(([key, vals]) => {
|
||||
if (!vals || vals.size === 0) return false;
|
||||
const def = COLUMN_DEFS[key];
|
||||
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 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
|
||||
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
||||
@@ -871,7 +1135,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 exportBtnRef = useRef(null);
|
||||
@@ -962,7 +1226,7 @@ export default function ReportingPage({ filterDate }) {
|
||||
Metric Graphs
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Open vs Closed donut */}
|
||||
<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' }}>
|
||||
@@ -974,6 +1238,25 @@ export default function ReportingPage({ filterDate }) {
|
||||
loading={countsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||
|
||||
{/* Action Coverage donut */}
|
||||
<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' }}>
|
||||
Action Coverage
|
||||
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered</span>}
|
||||
</div>
|
||||
<ActionCoverageDonut
|
||||
findings={findings}
|
||||
activeSegment={actionFilter}
|
||||
onSegmentClick={(key) => {
|
||||
setExcFilter(null);
|
||||
setActionFilter(key);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1011,7 +1294,46 @@ export default function ReportingPage({ filterDate }) {
|
||||
|
||||
{/* Action buttons */}
|
||||
<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
|
||||
onClick={() => setColumnFilters({})}
|
||||
style={{
|
||||
@@ -1188,7 +1510,7 @@ export default function ReportingPage({ filterDate }) {
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
||||
>
|
||||
{visibleCols.map((col) => (
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} />
|
||||
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user