2026-05-22 11:12:45 -06:00
import React , { useState , useEffect , useCallback , useMemo } from 'react' ;
2026-05-27 11:07:32 -06:00
import { ListTodo , RefreshCw , CheckSquare , Square , Loader , AlertCircle , X , Plus , CheckCircle , ChevronDown , ChevronRight } from 'lucide-react' ;
2026-05-22 11:12:45 -06:00
import { useAuth } from '../../contexts/AuthContext' ;
import ConsolidationModal from '../ConsolidationModal' ;
import { generateConsolidatedSummary , generateConsolidatedDescription , extractFirstCve , extractCommonVendor } from '../../utils/jiraConsolidation' ;
2026-05-27 11:07:32 -06:00
import { groupQueueItems } from '../../utils/queueGrouping' ;
Add vendor-specific issue type dropdown for Jira ticket creation
When the Project Key field contains a vendor project key (e.g. AA_VECIMA),
the Issue Type dropdown switches from STEAM types (Story, Epic, Program,
Project, Reservation, Automation Maintenance) to vendor types (Epic, Story,
Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release
Candidate, Documentation).
- Add VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES constants
- Add isVendorProject() and getIssueTypesForProject() pure functions
- Update JiraPage modal with context-aware dropdown and reset on switch
- Update Ivanti queue modal with project_key and issue_type fields
- Add property-based tests for determination logic and state transitions
2026-05-27 15:08:08 -06:00
import { VENDOR _PROJECT _KEYS , VENDOR _ISSUE _TYPES , STEAM _ISSUE _TYPES , isVendorProject , getIssueTypesForProject } from './JiraPage' ;
2026-05-22 11:12:45 -06:00
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' ,
} ,
2026-05-27 11:07:32 -06:00
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' ,
} ,
2026-05-22 11:12:45 -06:00
} ;
// ---------------------------------------------------------------------------
// 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 [ singleJiraItem , setSingleJiraItem ] = useState ( null ) ;
Add vendor-specific issue type dropdown for Jira ticket creation
When the Project Key field contains a vendor project key (e.g. AA_VECIMA),
the Issue Type dropdown switches from STEAM types (Story, Epic, Program,
Project, Reservation, Automation Maintenance) to vendor types (Epic, Story,
Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release
Candidate, Documentation).
- Add VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES constants
- Add isVendorProject() and getIssueTypesForProject() pure functions
- Update JiraPage modal with context-aware dropdown and reset on switch
- Update Ivanti queue modal with project_key and issue_type fields
- Add property-based tests for determination logic and state transitions
2026-05-27 15:08:08 -06:00
const [ singleJiraForm , setSingleJiraForm ] = useState ( { cve _id : '' , vendor : '' , summary : '' , description : '' , source _context : 'ivanti_queue' , project _key : '' , issue _type : '' } ) ;
2026-05-22 11:12:45 -06:00
const [ singleJiraError , setSingleJiraError ] = useState ( null ) ;
const [ singleJiraSaving , setSingleJiraSaving ] = useState ( false ) ;
const [ singleJiraSummaryError , setSingleJiraSummaryError ] = useState ( null ) ;
2026-05-27 11:07:32 -06:00
// Collapse state for grouped sections (Requirement 2.2, 2.7)
const [ collapsedSections , setCollapsedSections ] = useState ( { } ) ;
2026-05-22 11:12:45 -06:00
// ---------------------------------------------------------------------------
// 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 ] ) ;
2026-05-27 11:07:32 -06:00
// ---------------------------------------------------------------------------
// 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 ] ,
} ) ) ;
} , [ ] ) ;
2026-05-22 11:12:45 -06:00
// ---------------------------------------------------------------------------
// 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' ,
Add vendor-specific issue type dropdown for Jira ticket creation
When the Project Key field contains a vendor project key (e.g. AA_VECIMA),
the Issue Type dropdown switches from STEAM types (Story, Epic, Program,
Project, Reservation, Automation Maintenance) to vendor types (Epic, Story,
Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release
Candidate, Documentation).
- Add VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES constants
- Add isVendorProject() and getIssueTypesForProject() pure functions
- Update JiraPage modal with context-aware dropdown and reset on switch
- Update Ivanti queue modal with project_key and issue_type fields
- Add property-based tests for determination logic and state transitions
2026-05-27 15:08:08 -06:00
project _key : '' ,
issue _type : '' ,
2026-05-22 11:12:45 -06:00
} ) ;
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 < / s p a n >
< / d i v >
{ /* 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 < / >
}
< / b u t t o n >
) }
{ /* Selection count indicator (Requirement 1.3) */ }
{ selectionMode && selectedIds . size > 0 && (
< span style = { STYLES . selectionCount } >
{ selectedIds . size } selected
< / s p a n >
) }
< / d i v >
< 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
< / b u t t o n >
< / d i v >
< / d i v >
{ /* 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 } < / s p a n >
< / d i v >
) }
{ /* 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 ...
< / d i v >
< / d i v >
) }
{ /* 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 .
< / d i v >
< / d i v >
) }
{ /* 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 < / s p a n >
< span style = { { ... STYLES . tableHeaderLabel , width : '80px' , textAlign : 'center' } } > Type < / s p a n >
< span style = { { ... STYLES . tableHeaderLabel , width : '120px' } } > Vendor < / s p a n >
< span style = { { ... STYLES . tableHeaderLabel , width : '120px' } } > Host < / s p a n >
< / d i v >
2026-05-27 11:07:32 -06:00
{ /* Grouped sections with collapsible headers (Requirements 2.1, 2.3– 2.6, 3.1, 3.2) */ }
{ groupedSections . map ( ( section ) => {
const isCollapsed = ! ! collapsedSections [ section . key ] ;
2026-05-22 11:12:45 -06:00
return (
2026-05-27 11:07:32 -06:00
< 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 } < / s p a n >
< span style = { STYLES . sectionCount } > ( { section . items . length } ) < / s p a n >
2026-05-22 11:12:45 -06:00
< / d i v >
2026-05-27 11:07:32 -06:00
{ /* 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 }
< / d i v >
{ cveDisplay && (
< div style = { {
fontFamily : 'monospace' ,
fontSize : '0.65rem' ,
color : '#64748B' ,
marginTop : '2px' ,
overflow : 'hidden' ,
textOverflow : 'ellipsis' ,
whiteSpace : 'nowrap' ,
} } title = { cves . join ( ', ' ) } >
{ cveDisplay }
< / d i v >
) }
< / d i v >
{ /* 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 }
< / s p a n >
< / d i v >
{ /* 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 || '—' }
< / d i v >
{ /* 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 }
< / d i v >
) }
{ item . ip _address && (
< div style = { {
fontFamily : 'monospace' ,
fontSize : '0.62rem' ,
color : '#10B981' ,
marginTop : item . hostname ? '1px' : 0 ,
} } >
{ item . ip _address }
< / d i v >
) }
< / d i v >
2026-05-22 11:12:45 -06:00
< / d i v >
2026-05-27 11:07:32 -06:00
) ;
} ) }
2026-05-22 11:12:45 -06:00
< / d i v >
) ;
} ) }
< / >
) }
{ /* 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
< / d i v >
) }
< / d i v >
{ /* Floating Action Bar (Requirements 2.1, 2.2) */ }
{ selectionMode && selectedIds . size > 0 && (
< div style = { STYLES . floatingBar } >
< span style = { STYLES . floatingBarBadge } >
{ selectedIds . size } selected
< / s p a n >
< 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
< / b u t t o n >
< button
onClick = { cancelSelection }
style = { STYLES . btnCancel }
title = "Cancel selection"
>
< X style = { { width : '14px' , height : '14px' } } / >
Cancel
< / b u t t o n >
< / d i v >
) }
{ /* 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 < / h 3 >
< button onClick = { ( ) => setShowSingleJiraModal ( false ) } style = { { background : 'none' , border : 'none' , color : '#94A3B8' , cursor : 'pointer' } } > < X style = { { width : '18px' , height : '18px' } } / > < / b u t t o n >
< / d i v >
< 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 } < / s p a n >
< / p >
{ singleJiraError && < div style = { { color : '#FCA5A5' , fontSize : '0.85rem' , marginBottom : '0.75rem' } } > { singleJiraError } < / d i v > }
< 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 ) < / l a b e l >
< input style = { STYLES . input } placeholder = "e.g. CVE-2024-12345" value = { singleJiraForm . cve _id } onChange = { e => setSingleJiraForm ( f => ( { ... f , cve _id : e . target . value } ) ) } / >
< / d i v >
< div >
< label style = { { fontSize : '0.75rem' , color : '#94A3B8' , display : 'block' , marginBottom : '0.25rem' } } > Vendor ( optional ) < / l a b e l >
< input style = { STYLES . input } placeholder = "e.g. Microsoft" value = { singleJiraForm . vendor } onChange = { e => setSingleJiraForm ( f => ( { ... f , vendor : e . target . value } ) ) } / >
< / d i v >
< div >
< label style = { { fontSize : '0.75rem' , color : '#94A3B8' , display : 'block' , marginBottom : '0.25rem' } } > Summary < span style = { { color : '#F59E0B' } } > * < / s p a n > < / l a b e l >
< 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 } < / d i v > }
< / d i v >
< div >
< label style = { { fontSize : '0.75rem' , color : '#94A3B8' , display : 'block' , marginBottom : '0.25rem' } } > Source Context < / l a b e l >
< input
style = { { ... STYLES . input , opacity : 0.7 , cursor : 'not-allowed' } }
value = "ivanti_queue"
disabled
/ >
< / d i v >
< div >
< label style = { { fontSize : '0.75rem' , color : '#94A3B8' , display : 'block' , marginBottom : '0.25rem' } } > Description < / l a b e l >
< textarea
style = { { ... STYLES . input , minHeight : '100px' , resize : 'vertical' } }
placeholder = "Detailed description..."
value = { singleJiraForm . description }
onChange = { e => setSingleJiraForm ( f => ( { ... f , description : e . target . value } ) ) }
/ >
< / d i v >
Add vendor-specific issue type dropdown for Jira ticket creation
When the Project Key field contains a vendor project key (e.g. AA_VECIMA),
the Issue Type dropdown switches from STEAM types (Story, Epic, Program,
Project, Reservation, Automation Maintenance) to vendor types (Epic, Story,
Task, Defect, Production Defect/Incident Fix, New Feature, Spike, Release
Candidate, Documentation).
- Add VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES constants
- Add isVendorProject() and getIssueTypesForProject() pure functions
- Update JiraPage modal with context-aware dropdown and reset on switch
- Update Ivanti queue modal with project_key and issue_type fields
- Add property-based tests for determination logic and state transitions
2026-05-27 15:08:08 -06:00
< 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 ) < / l a b e l >
< 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 ,
} ) ) ;
} } / >
< / d i v >
< div >
< label style = { { fontSize : '0.75rem' , color : '#94A3B8' , display : 'block' , marginBottom : '0.25rem' } } > Issue Type < / l a b e l >
< select style = { { ... STYLES . input , cursor : 'pointer' } } value = { singleJiraForm . issue _type } onChange = { e => setSingleJiraForm ( f => ( { ... f , issue _type : e . target . value } ) ) } >
< option value = "" > Story ( default ) < / o p t i o n >
{ getIssueTypesForProject ( singleJiraForm . project _key , VENDOR _PROJECT _KEYS , VENDOR _ISSUE _TYPES , STEAM _ISSUE _TYPES ) . map ( type => (
< option key = { type } value = { type } > { type } < / o p t i o n >
) ) }
< / s e l e c t >
< / d i v >
< / d i v >
2026-05-22 11:12:45 -06:00
< 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
< / b u t t o n >
< / d i v >
< / d i v >
< / d i v >
) }
{ /* Consolidation Modal (Requirement 2.3) */ }
{ showConsolidationModal && (
< ConsolidationModal
items = { selectedQueueItems }
onClose = { ( ) => setShowConsolidationModal ( false ) }
onSuccess = { handleConsolidationSuccess }
/ >
) }
< / d i v >
) ;
}