diff --git a/backend/routes/ivantiFindings.js b/backend/routes/ivantiFindings.js
index 2f41ce2..342b1d2 100644
--- a/backend/routes/ivantiFindings.js
+++ b/backend/routes/ivantiFindings.js
@@ -130,6 +130,39 @@ function extractFinding(f) {
// CVE list: vulnerabilities.vulnInfoList[].cve
const cves = (f.vulnerabilities?.vulnInfoList || []).map(v => v.cve).filter(Boolean);
+ // Workflow: flatten all distribution buckets, prioritise FP# over SYS#
+ const wfDist = f.workflowDistribution || {};
+ const allWfEntries = [
+ ...(wfDist.actionableWorkflows || []),
+ ...(wfDist.requestedWorkflows || []),
+ ...(wfDist.approvedWorkflows || []),
+ ...(wfDist.reworkedWorkflows || []),
+ ...(wfDist.rejectedWorkflows || []),
+ ...(wfDist.expiredWorkflows || []),
+ ...(wfDist.latestSystemWorkflows || []),
+ ];
+ // FP# (False Positive tickets) take priority over SYS# (system workflows)
+ const fpEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('FP#'));
+ const sysEntry = allWfEntries.find(w => (w.generatedId || '').startsWith('SYS#'));
+ const wfEntry = fpEntry || sysEntry || allWfEntries[0] || null;
+
+ // If the distribution didn't surface an FP#, also check workflowGeneratedNames directly.
+ // (Some FP# tickets only appear in the names list without full state info.)
+ const generatedNames = f.workflowGeneratedNames || [];
+ const fpFromNames = !fpEntry
+ ? generatedNames.find(n => n.startsWith('FP#')) || null
+ : null;
+
+ const workflow = wfEntry ? {
+ id: wfEntry.generatedId || '',
+ state: wfEntry.state || '',
+ type: wfEntry.type || wfEntry.acronym || '',
+ } : fpFromNames ? {
+ id: fpFromNames,
+ state: '',
+ type: 'FP',
+ } : null;
+
return {
id: String(f.id),
title: f.title || '',
@@ -143,7 +176,8 @@ function extractFinding(f) {
dueDate,
lastFoundOn: f.lastFoundOn || '',
buOwnership,
- cves
+ cves,
+ workflow
};
}
diff --git a/frontend/src/components/pages/ReportingPage.js b/frontend/src/components/pages/ReportingPage.js
index 37d4a92..b0345f7 100644
--- a/frontend/src/components/pages/ReportingPage.js
+++ b/frontend/src/components/pages/ReportingPage.js
@@ -3,7 +3,7 @@ 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';
+const STORAGE_KEY = 'steam_findings_columns_v2';
// ---------------------------------------------------------------------------
// Column definitions — source of truth for labels, sort behaviour, rendering
@@ -17,8 +17,9 @@ const COLUMN_DEFS = {
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 },
+ slaStatus: { label: 'SLA', sortable: true, filterable: true },
buOwnership: { label: 'BU', sortable: true, filterable: true },
+ workflow: { label: 'Workflow', sortable: true, filterable: true },
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
note: { label: 'Notes', sortable: false, filterable: false },
};
@@ -34,6 +35,7 @@ const DEFAULT_COLUMN_ORDER = [
{ key: 'dueDate', visible: true },
{ key: 'slaStatus', visible: true },
{ key: 'buOwnership', visible: true },
+ { key: 'workflow', visible: true },
{ key: 'lastFoundOn', visible: true },
{ key: 'note', visible: true },
];
@@ -75,6 +77,7 @@ function getVal(finding, key) {
case 'slaStatus': return finding.slaStatus ?? '';
case 'cves': return (finding.cves || []).length; // sort by CVE count
case 'buOwnership': return finding.buOwnership ?? '';
+ case 'workflow': return finding.workflow?.id ?? '';
case 'lastFoundOn': return finding.lastFoundOn ?? '';
case 'note': return finding.note ?? '';
default: return '';
@@ -87,6 +90,7 @@ function getVal(finding, key) {
function getFilterVal(finding, key) {
if (key === 'severity') return finding.vrrGroup || '';
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
+ if (key === 'workflow') return finding.workflow?.id || '';
return String(getVal(finding, key) ?? '');
}
@@ -121,6 +125,18 @@ function dueDateColor(dueDate) {
return '#94A3B8';
}
+function workflowStyle(state) {
+ switch ((state || '').toLowerCase()) {
+ case 'approved': return { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.35)', text: '#10B981' };
+ case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' };
+ case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', text: '#F59E0B' };
+ case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.35)', text: '#F59E0B' };
+ case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.35)', text: '#EF4444' };
+ case 'expired': return { bg: 'rgba(100,116,139,0.12)', border: 'rgba(100,116,139,0.3)', text: '#64748B' };
+ default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' };
+ }
+}
+
function SortIcon({ colKey, sort }) {
if (sort.field !== colKey) return