Files
cve-dashboard/frontend/src/components/pages/IvantiTodoQueuePage.js
Jordan Ramos fe82362afa Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
  operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
  the xlsx library
- Add LoaderModal component with operation type selection, column
  checkboxes, bulk defaults with per-row overrides, editable preview
  table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
  CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
  action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00

1043 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ConsolidationModal from '../ConsolidationModal';
import LoaderModal from '../LoaderModal';
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
import { groupQueueItems } from '../../utils/queueGrouping';
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
// ---------------------------------------------------------------------------
// Styles — matches dark theme tactical intelligence aesthetic
// ---------------------------------------------------------------------------
const STYLES = {
page: {
minHeight: '60vh',
},
card: {
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98))',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '12px',
padding: '1.5rem',
marginBottom: '1rem',
},
header: {
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#0EA5E9',
textTransform: 'uppercase',
letterSpacing: '0.15em',
marginBottom: '1rem',
},
toolbar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '1rem',
flexWrap: 'wrap',
gap: '0.5rem',
},
toolbarLeft: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
},
toolbarRight: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
},
btn: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(14, 165, 233, 0.3)',
background: 'rgba(14, 165, 233, 0.1)',
color: '#7DD3FC',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnActive: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(14, 165, 233, 0.6)',
background: 'rgba(14, 165, 233, 0.25)',
color: '#0EA5E9',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
boxShadow: '0 0 12px rgba(14, 165, 233, 0.2)',
},
selectionCount: {
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '999px',
padding: '0.25rem 0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem',
},
tableHeader: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
marginBottom: '0.5rem',
},
tableHeaderLabel: {
fontFamily: 'monospace',
fontSize: '0.65rem',
fontWeight: 700,
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
queueItem: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.625rem',
padding: '0.625rem 0.75rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.04)',
border: '1px solid rgba(14, 165, 233, 0.1)',
transition: 'background 0.15s, border-color 0.15s',
},
queueItemSelected: {
display: 'flex',
alignItems: 'flex-start',
gap: '0.625rem',
padding: '0.625rem 0.75rem',
marginBottom: '0.25rem',
borderRadius: '0.375rem',
background: 'rgba(14, 165, 233, 0.08)',
border: '1px solid rgba(14, 165, 233, 0.3)',
transition: 'background 0.15s, border-color 0.15s',
},
checkbox: {
accentColor: '#0EA5E9',
width: '16px',
height: '16px',
flexShrink: 0,
marginTop: '2px',
cursor: 'pointer',
},
selectAllCheckbox: {
accentColor: '#0EA5E9',
width: '14px',
height: '14px',
cursor: 'pointer',
},
floatingBar: {
position: 'fixed',
bottom: '1.5rem',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 1.25rem',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 0.99))',
border: '1px solid rgba(14, 165, 233, 0.3)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5), 0 0 16px rgba(14, 165, 233, 0.1)',
zIndex: 50,
},
floatingBarBadge: {
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#F59E0B',
background: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.3)',
borderRadius: '999px',
padding: '0.25rem 0.75rem',
},
btnSuccess: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(16, 185, 129, 0.4)',
background: 'rgba(16, 185, 129, 0.15)',
color: '#6EE7B7',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
btnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
btnCancel: {
padding: '0.5rem 1rem',
borderRadius: '8px',
border: '1px solid rgba(148, 163, 184, 0.3)',
background: 'rgba(148, 163, 184, 0.08)',
color: '#94A3B8',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
transition: 'all 0.2s',
},
modal: {
position: 'fixed',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
modalBackdrop: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
},
modalContent: {
position: 'relative',
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
border: '1px solid rgba(14, 165, 233, 0.25)',
borderRadius: '16px',
padding: '2rem',
width: '90%',
maxWidth: '520px',
maxHeight: '85vh',
overflowY: 'auto',
zIndex: 101,
},
input: {
background: 'rgba(15, 23, 42, 0.8)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
padding: '0.5rem 0.75rem',
color: '#F8FAFC',
fontSize: '0.85rem',
width: '100%',
outline: 'none',
},
sectionHeaderInventory: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
marginTop: '0.5rem',
borderBottom: '1px solid rgba(16, 185, 129, 0.2)',
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#10B981',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
sectionHeaderVendor: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
marginTop: '0.5rem',
borderBottom: '1px solid rgba(148, 163, 184, 0.15)',
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontSize: '0.7rem',
fontWeight: 700,
color: '#94A3B8',
textTransform: 'uppercase',
letterSpacing: '0.1em',
},
sectionCount: {
fontFamily: 'monospace',
fontSize: '0.65rem',
fontWeight: 600,
color: '#64748B',
marginLeft: '0.25rem',
},
};
// ---------------------------------------------------------------------------
// IvantiTodoQueuePage — Full-page Ivanti queue with multi-select support
// ---------------------------------------------------------------------------
export default function IvantiTodoQueuePage() {
const { canWrite } = useAuth();
// Queue data state
const [queueItems, setQueueItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Ticket link badges state (Requirement 6.3, 6.4, 6.5)
const [ticketLinks, setTicketLinks] = useState({});
// Selection mode state (Requirement 1.1)
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState(new Set());
// Consolidation modal state (Requirement 2.3)
const [showConsolidationModal, setShowConsolidationModal] = useState(false);
// Single-item Jira creation modal state (Requirement 2.4)
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
const [showLoaderModal, setShowLoaderModal] = useState(false);
const [singleJiraItem, setSingleJiraItem] = useState(null);
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
const [singleJiraError, setSingleJiraError] = useState(null);
const [singleJiraSaving, setSingleJiraSaving] = useState(false);
const [singleJiraSummaryError, setSingleJiraSummaryError] = useState(null);
// Collapse state for grouped sections (Requirement 2.2, 2.7)
const [collapsedSections, setCollapsedSections] = useState({});
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const fetchQueue = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue`, { credentials: 'include' });
const data = await res.json();
if (res.ok) {
// Parse cves from cves_json if not already parsed
const parsed = data.map((item) => {
if (item.cves) return item;
let cves = [];
if (item.cves_json) {
try { cves = JSON.parse(item.cves_json); } catch { cves = []; }
}
return { ...item, cves };
});
setQueueItems(parsed);
} else {
setError(data.error || 'Failed to fetch queue items.');
}
} catch (e) {
setError('Network error — could not fetch queue items.');
console.error('Error fetching queue:', e);
} finally {
setLoading(false);
}
}, []);
// ---------------------------------------------------------------------------
// Fetch ticket link associations (Requirements 6.3, 6.4, 6.5)
// ---------------------------------------------------------------------------
const fetchTicketLinks = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/ivanti/todo-queue/ticket-links`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
setTicketLinks(data.links || {});
}
} catch (e) {
console.error('Error fetching ticket links:', e);
}
}, []);
useEffect(() => {
fetchQueue();
fetchTicketLinks();
}, [fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Visible items — only pending items are selectable
// ---------------------------------------------------------------------------
const visibleItems = useMemo(() => {
return queueItems.filter((item) => item.status === 'pending');
}, [queueItems]);
// ---------------------------------------------------------------------------
// Grouped sections — hybrid Inventory + vendor grouping (Requirements 1.11.7)
// ---------------------------------------------------------------------------
const groupedSections = useMemo(() => groupQueueItems(visibleItems), [visibleItems]);
// ---------------------------------------------------------------------------
// Toggle section collapse (Requirement 2.2, 2.7)
// ---------------------------------------------------------------------------
const toggleSection = useCallback((sectionKey) => {
setCollapsedSections((prev) => ({
...prev,
[sectionKey]: !prev[sectionKey],
}));
}, []);
// ---------------------------------------------------------------------------
// Selection mode toggle (Requirement 1.1, 1.5)
// When deactivated, clear all selections
// ---------------------------------------------------------------------------
const toggleSelectionMode = useCallback(() => {
setSelectionMode((prev) => {
if (prev) {
// Deactivating — clear selections (Requirement 1.5)
setSelectedIds(new Set());
}
return !prev;
});
}, []);
// ---------------------------------------------------------------------------
// Individual item selection toggle (Requirement 1.2)
// ---------------------------------------------------------------------------
const toggleItemSelection = useCallback((id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
// ---------------------------------------------------------------------------
// Select All toggle (Requirement 1.4)
// Toggles all visible (filtered) queue item IDs into/out of selectedIds
// ---------------------------------------------------------------------------
const allVisibleSelected = useMemo(() => {
if (visibleItems.length === 0) return false;
return visibleItems.every((item) => selectedIds.has(item.id));
}, [visibleItems, selectedIds]);
const someVisibleSelected = useMemo(() => {
if (visibleItems.length === 0) return false;
return visibleItems.some((item) => selectedIds.has(item.id)) && !allVisibleSelected;
}, [visibleItems, selectedIds, allVisibleSelected]);
const toggleSelectAll = useCallback(() => {
if (allVisibleSelected) {
// Deselect all visible
setSelectedIds((prev) => {
const next = new Set(prev);
visibleItems.forEach((item) => next.delete(item.id));
return next;
});
} else {
// Select all visible
setSelectedIds((prev) => {
const next = new Set(prev);
visibleItems.forEach((item) => next.add(item.id));
return next;
});
}
}, [allVisibleSelected, visibleItems]);
// ---------------------------------------------------------------------------
// Preserve selections on scroll/re-render (Requirement 1.6)
// Clean up selectedIds that no longer exist in the queue
// ---------------------------------------------------------------------------
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const validIds = new Set(queueItems.map((i) => i.id));
const next = new Set([...prev].filter((id) => validIds.has(id)));
return next.size === prev.size ? prev : next;
});
}, [queueItems]);
// ---------------------------------------------------------------------------
// Selected queue items (full objects) for modal use
// ---------------------------------------------------------------------------
const selectedQueueItems = useMemo(() => {
return queueItems.filter(item => selectedIds.has(item.id));
}, [queueItems, selectedIds]);
// ---------------------------------------------------------------------------
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
// ---------------------------------------------------------------------------
const handleCreateJiraTicket = useCallback(() => {
if (selectedIds.size === 0) return;
if (selectedIds.size === 1) {
// Single item — open single-item Jira creation modal (Requirement 2.4)
const item = queueItems.find(i => selectedIds.has(i.id));
if (!item) return;
setSingleJiraItem(item);
const items = [item];
setSingleJiraForm({
cve_id: extractFirstCve(items),
vendor: extractCommonVendor(items),
summary: generateConsolidatedSummary(items),
description: generateConsolidatedDescription(items),
source_context: 'ivanti_queue',
project_key: '',
issue_type: '',
});
setSingleJiraError(null);
setSingleJiraSummaryError(null);
setShowSingleJiraModal(true);
} else {
// Multiple items — open Consolidation Modal (Requirement 2.3)
setShowConsolidationModal(true);
}
}, [selectedIds, queueItems]);
// ---------------------------------------------------------------------------
// Consolidation modal success handler
// ---------------------------------------------------------------------------
const handleConsolidationSuccess = useCallback(() => {
setShowConsolidationModal(false);
setSelectedIds(new Set());
setSelectionMode(false);
fetchQueue();
fetchTicketLinks();
}, [fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Single-item Jira creation — submit handler
// ---------------------------------------------------------------------------
const submitSingleJira = useCallback(async () => {
setSingleJiraSummaryError(null);
const trimmedSummary = (singleJiraForm.summary || '').trim();
if (!trimmedSummary) {
setSingleJiraSummaryError('Summary is required.');
return;
}
if (trimmedSummary.length > 255) {
setSingleJiraSummaryError('Summary must be 255 characters or fewer.');
return;
}
setSingleJiraError(null);
setSingleJiraSaving(true);
try {
const res = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(singleJiraForm),
});
const data = await res.json();
if (!res.ok && res.status !== 207) {
throw new Error(data.error || `HTTP ${res.status}`);
}
// If we have a ticket ID and a queue item, link them via junction table
if (data.id && singleJiraItem) {
try {
await fetch(`${API_BASE}/jira-tickets/${data.id}/queue-items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ queue_item_ids: [singleJiraItem.id] }),
});
} catch (_) { /* junction link is best-effort */ }
}
setShowSingleJiraModal(false);
setSingleJiraItem(null);
setSelectedIds(new Set());
setSelectionMode(false);
fetchQueue();
fetchTicketLinks();
} catch (err) {
setSingleJiraError(err.message);
} finally {
setSingleJiraSaving(false);
}
}, [singleJiraForm, singleJiraItem, fetchQueue, fetchTicketLinks]);
// ---------------------------------------------------------------------------
// Cancel selection mode from floating bar
// ---------------------------------------------------------------------------
const cancelSelection = useCallback(() => {
setSelectedIds(new Set());
setSelectionMode(false);
}, []);
// ---------------------------------------------------------------------------
// Workflow type color helper
// ---------------------------------------------------------------------------
const getWorkflowColor = (workflowType) => {
switch (workflowType) {
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
default: return { col: '#94A3B8', rgb: '148,163,184' };
}
};
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div style={STYLES.page}>
<div style={STYLES.card}>
{/* Page header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1.25rem' }}>
<ListTodo style={{ width: '20px', height: '20px', color: '#0EA5E9' }} />
<span style={STYLES.header}>Ivanti Todo Queue</span>
</div>
{/* Toolbar */}
<div style={STYLES.toolbar}>
<div style={STYLES.toolbarLeft}>
{/* Select toggle button (Requirement 1.1) */}
{canWrite() && (
<button
onClick={toggleSelectionMode}
style={selectionMode ? STYLES.btnActive : STYLES.btn}
title={selectionMode ? 'Exit selection mode' : 'Enter selection mode'}
>
{selectionMode
? <><CheckSquare style={{ width: '14px', height: '14px' }} /> Selecting</>
: <><Square style={{ width: '14px', height: '14px' }} /> Select</>
}
</button>
)}
{/* Selection count indicator (Requirement 1.3) */}
{selectionMode && selectedIds.size > 0 && (
<span style={STYLES.selectionCount}>
{selectedIds.size} selected
</span>
)}
</div>
<div style={STYLES.toolbarRight}>
{/* Refresh button */}
<button
onClick={fetchQueue}
style={STYLES.btn}
disabled={loading}
title="Refresh queue"
>
<RefreshCw style={{ width: '14px', height: '14px', animation: loading ? 'spin 1s linear infinite' : 'none' }} />
Refresh
</button>
</div>
</div>
{/* Error state */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.75rem 1rem',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '0.5rem',
marginBottom: '1rem',
}}>
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{error}</span>
</div>
)}
{/* Loading state */}
{loading && queueItems.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569', marginTop: '0.75rem' }}>
Loading queue items...
</div>
</div>
)}
{/* Empty state */}
{!loading && queueItems.length === 0 && !error && (
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
<ListTodo style={{ width: '32px', height: '32px', color: '#1E293B', margin: '0 auto' }} />
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#334155', marginTop: '0.75rem' }}>
No items in queue.
</div>
</div>
)}
{/* Queue items table */}
{!loading && visibleItems.length > 0 && (
<>
{/* Table header with Select All (Requirement 1.4) */}
<div style={STYLES.tableHeader}>
{selectionMode && (
<input
type="checkbox"
checked={allVisibleSelected}
ref={(el) => { if (el) el.indeterminate = someVisibleSelected; }}
onChange={toggleSelectAll}
style={STYLES.selectAllCheckbox}
title={allVisibleSelected ? 'Deselect all' : 'Select all'}
aria-label="Select all queue items"
/>
)}
<span style={{ ...STYLES.tableHeaderLabel, flex: 1 }}>Finding</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '80px', textAlign: 'center' }}>Type</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Vendor</span>
<span style={{ ...STYLES.tableHeaderLabel, width: '120px' }}>Host</span>
</div>
{/* Grouped sections with collapsible headers (Requirements 2.1, 2.32.6, 3.1, 3.2) */}
{groupedSections.map((section) => {
const isCollapsed = !!collapsedSections[section.key];
return (
<div key={section.key}>
{/* Section Header */}
<div
onClick={() => toggleSection(section.key)}
style={section.type === 'inventory' ? STYLES.sectionHeaderInventory : STYLES.sectionHeaderVendor}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSection(section.key);
}
}}
aria-expanded={!isCollapsed}
aria-label={`${section.label} section, ${section.items.length} items`}
>
{isCollapsed
? <ChevronRight style={{ width: '14px', height: '14px' }} />
: <ChevronDown style={{ width: '14px', height: '14px' }} />
}
<span>{section.label}</span>
<span style={STYLES.sectionCount}>({section.items.length})</span>
</div>
{/* Section Body — only rendered when expanded */}
{!isCollapsed && section.items.map((item) => {
const isSelected = selectedIds.has(item.id);
const wfColor = getWorkflowColor(item.workflow_type);
const cves = item.cves || [];
const cveDisplay = cves.length > 0
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
: '';
return (
<div
key={item.id}
style={isSelected ? STYLES.queueItemSelected : STYLES.queueItem}
onClick={selectionMode ? () => toggleItemSelection(item.id) : undefined}
role={selectionMode ? 'button' : undefined}
tabIndex={selectionMode ? 0 : undefined}
onKeyDown={selectionMode ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleItemSelection(item.id); } } : undefined}
>
{/* Selection checkbox (Requirement 1.2) */}
{selectionMode && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => { e.stopPropagation(); toggleItemSelection(item.id); }}
onClick={(e) => e.stopPropagation()}
style={STYLES.checkbox}
aria-label={`Select ${item.finding_title || item.finding_id}`}
/>
)}
{/* Finding info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'monospace',
fontSize: '0.75rem',
fontWeight: 600,
color: '#CBD5E1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.finding_title || item.finding_id}>
{item.finding_title || item.finding_id}
</div>
{cveDisplay && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#64748B',
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={cves.join(', ')}>
{cveDisplay}
</div>
)}
</div>
{/* Ticket link badge (Requirements 6.3, 6.4) */}
{ticketLinks[item.id] && (
<a
href={ticketLinks[item.id].jira_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: '#6EE7B7',
background: 'rgba(16, 185, 129, 0.1)',
border: '1px solid rgba(16, 185, 129, 0.3)',
borderRadius: '999px',
padding: '0.15rem 0.5rem',
textDecoration: 'none',
whiteSpace: 'nowrap',
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
transition: 'all 0.2s',
}}
title={`Open ${ticketLinks[item.id].ticket_key} in Jira`}
>
{ticketLinks[item.id].ticket_key}
</a>
)}
{/* Workflow type badge */}
<div style={{
width: '80px',
textAlign: 'center',
flexShrink: 0,
}}>
<span style={{
fontFamily: 'monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: wfColor.col,
background: `rgba(${wfColor.rgb}, 0.1)`,
border: `1px solid rgba(${wfColor.rgb}, 0.3)`,
borderRadius: '4px',
padding: '0.15rem 0.4rem',
textTransform: 'uppercase',
}}>
{item.workflow_type}
</span>
</div>
{/* Vendor */}
<div style={{
width: '120px',
flexShrink: 0,
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.vendor}>
{item.vendor || '—'}
</div>
{/* Hostname / IP */}
<div style={{
width: '120px',
flexShrink: 0,
minWidth: 0,
}}>
{item.hostname && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.65rem',
color: '#94A3B8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={item.hostname}>
{item.hostname}
</div>
)}
{item.ip_address && (
<div style={{
fontFamily: 'monospace',
fontSize: '0.62rem',
color: '#10B981',
marginTop: item.hostname ? '1px' : 0,
}}>
{item.ip_address}
</div>
)}
</div>
</div>
);
})}
</div>
);
})}
</>
)}
{/* Completed items count */}
{!loading && queueItems.filter(i => i.status === 'complete').length > 0 && (
<div style={{
marginTop: '1rem',
padding: '0.5rem 0.75rem',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
fontFamily: 'monospace',
fontSize: '0.68rem',
color: '#334155',
}}>
{queueItems.filter(i => i.status === 'complete').length} completed item(s) hidden
</div>
)}
</div>
{/* Floating Action Bar (Requirements 2.1, 2.2) */}
{selectionMode && selectedIds.size > 0 && (
<div style={STYLES.floatingBar}>
<span style={STYLES.floatingBarBadge}>
{selectedIds.size} selected
</span>
<button
onClick={handleCreateJiraTicket}
disabled={selectedIds.size === 0}
style={selectedIds.size === 0 ? { ...STYLES.btnSuccess, ...STYLES.btnDisabled } : STYLES.btnSuccess}
title={selectedIds.size === 1 ? 'Create Jira ticket for selected item' : `Create consolidated Jira ticket for ${selectedIds.size} items`}
>
<Plus style={{ width: '14px', height: '14px' }} />
Create Jira Ticket
</button>
{(() => {
const selectedItems = queueItems.filter(i => selectedIds.has(i.id));
const hasCardGranite = selectedItems.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
return hasCardGranite ? (
<button
onClick={() => setShowLoaderModal(true)}
style={STYLES.btnSuccess}
title="Generate Granite Team_Device Loader Sheet from selected items"
>
<FileSpreadsheet style={{ width: '14px', height: '14px' }} />
Generate Loader Sheet
</button>
) : null;
})()}
<button
onClick={cancelSelection}
style={STYLES.btnCancel}
title="Cancel selection"
>
<X style={{ width: '14px', height: '14px' }} />
Cancel
</button>
</div>
)}
{/* Single-item Jira Creation Modal (Requirement 2.4) */}
{showSingleJiraModal && (
<div style={STYLES.modal}>
<div style={STYLES.modalBackdrop} onClick={() => setShowSingleJiraModal(false)} />
<div style={STYLES.modalContent}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<h3 style={{ margin: 0, color: '#F8FAFC', fontSize: '1rem' }}>Create Jira Ticket</h3>
<button onClick={() => setShowSingleJiraModal(false)} style={{ background: 'none', border: 'none', color: '#94A3B8', cursor: 'pointer' }}><X style={{ width: '18px', height: '18px' }} /></button>
</div>
<p style={{ fontSize: '0.8rem', color: '#94A3B8', marginTop: 0, marginBottom: '1rem' }}>
Create a Jira issue for: <span style={{ color: '#7DD3FC' }}>{singleJiraItem?.finding_title || singleJiraItem?.finding_id}</span>
</p>
{singleJiraError && <div style={{ color: '#FCA5A5', fontSize: '0.85rem', marginBottom: '0.75rem' }}>{singleJiraError}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>CVE ID (optional)</label>
<input style={STYLES.input} placeholder="e.g. CVE-2024-12345" value={singleJiraForm.cve_id} onChange={e => setSingleJiraForm(f => ({ ...f, cve_id: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Vendor (optional)</label>
<input style={STYLES.input} placeholder="e.g. Microsoft" value={singleJiraForm.vendor} onChange={e => setSingleJiraForm(f => ({ ...f, vendor: e.target.value }))} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Summary <span style={{ color: '#F59E0B' }}>*</span></label>
<input
style={{ ...STYLES.input, ...(singleJiraSummaryError ? { borderColor: 'rgba(239, 68, 68, 0.6)' } : {}) }}
placeholder="Issue summary (max 255 chars)"
value={singleJiraForm.summary}
onChange={e => { setSingleJiraForm(f => ({ ...f, summary: e.target.value })); if (singleJiraSummaryError) setSingleJiraSummaryError(null); }}
maxLength={255}
/>
{singleJiraSummaryError && <div style={{ color: '#FCA5A5', fontSize: '0.75rem', marginTop: '0.25rem' }}>{singleJiraSummaryError}</div>}
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Source Context</label>
<input
style={{ ...STYLES.input, opacity: 0.7, cursor: 'not-allowed' }}
value="ivanti_queue"
disabled
/>
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Description</label>
<textarea
style={{ ...STYLES.input, minHeight: '100px', resize: 'vertical' }}
placeholder="Detailed description..."
value={singleJiraForm.description}
onChange={e => setSingleJiraForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Project Key (optional)</label>
<input style={STYLES.input} placeholder="Uses .env default" value={singleJiraForm.project_key} onChange={e => {
const newKey = e.target.value.toUpperCase();
const wasVendor = isVendorProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS);
const isNowVendor = isVendorProject(newKey, VENDOR_PROJECT_KEYS);
setSingleJiraForm(f => ({
...f,
project_key: newKey,
issue_type: (wasVendor !== isNowVendor) ? '' : f.issue_type,
}));
}} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: '#94A3B8', display: 'block', marginBottom: '0.25rem' }}>Issue Type</label>
<select style={{ ...STYLES.input, cursor: 'pointer' }} value={singleJiraForm.issue_type} onChange={e => setSingleJiraForm(f => ({ ...f, issue_type: e.target.value }))}>
<option value="">Story (default)</option>
{getIssueTypesForProject(singleJiraForm.project_key, VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
</div>
<button
style={{ ...STYLES.btnSuccess, justifyContent: 'center', marginTop: '0.5rem' }}
onClick={submitSingleJira}
disabled={singleJiraSaving}
>
{singleJiraSaving ? <Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> : <CheckCircle style={{ width: '14px', height: '14px' }} />}
Create in Jira
</button>
</div>
</div>
</div>
)}
{/* Consolidation Modal (Requirement 2.3) */}
{showConsolidationModal && (
<ConsolidationModal
items={selectedQueueItems}
onClose={() => setShowConsolidationModal(false)}
onSuccess={handleConsolidationSuccess}
/>
)}
{/* Granite Loader Sheet Modal */}
<LoaderModal
isOpen={showLoaderModal}
onClose={() => setShowLoaderModal(false)}
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
/>
</div>
);
}