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.1–1.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 (
Create a Jira issue for: {singleJiraItem?.finding_title || singleJiraItem?.finding_id}
{singleJiraError &&