2026-05-22 11:12:45 -06:00
import React , { useState , useEffect , useCallback , useMemo } from 'react' ;
2026-06-08 14:07:59 -06:00
import { ListTodo , RefreshCw , CheckSquare , Square , Loader , AlertCircle , X , Plus , CheckCircle , ChevronDown , ChevronRight , FileSpreadsheet , FileText , Trash2 } from 'lucide-react' ;
2026-05-22 11:12:45 -06:00
import { useAuth } from '../../contexts/AuthContext' ;
import ConsolidationModal from '../ConsolidationModal' ;
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
import LoaderModal from '../LoaderModal' ;
2026-06-02 16:08:25 -06:00
import TemplateSelector from '../TemplateSelector' ;
2026-06-08 14:07:59 -06:00
import RemediationModal from '../RemediationModal' ;
import { generateConsolidatedSummary , generateConsolidatedDescription , extractFirstCve , extractCommonVendor , appendRemediationNotes } 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 ) ;
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
const [ showLoaderModal , setShowLoaderModal ] = useState ( false ) ;
2026-05-22 11:12:45 -06:00
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-06-02 16:08:25 -06:00
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
const [ templatePanelOpenId , setTemplatePanelOpenId ] = useState ( null ) ;
2026-06-08 14:07:59 -06:00
// Remediation Modal state — tracks which item has the modal open
const [ remediationModalItem , setRemediationModalItem ] = useState ( null ) ;
// Local note counts — allows updating badge without full page reload
const [ localNoteCounts , setLocalNoteCounts ] = useState ( { } ) ;
// Delete confirmation dialog state (Requirement 7)
const [ deleteConfirmItem , setDeleteConfirmItem ] = useState ( null ) ;
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-06-02 16:08:25 -06:00
// ---------------------------------------------------------------------------
// Toggle Archer Template Selector panel (Requirement 5.1)
// ---------------------------------------------------------------------------
const toggleTemplatePanel = useCallback ( ( itemId ) => {
setTemplatePanelOpenId ( ( prev ) => ( prev === itemId ? null : itemId ) ) ;
} , [ ] ) ;
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)
// ---------------------------------------------------------------------------
2026-06-08 14:07:59 -06:00
const handleCreateJiraTicket = useCallback ( async ( ) => {
2026-05-22 11:12:45 -06:00
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 ] ;
2026-06-08 14:07:59 -06:00
let description = generateConsolidatedDescription ( items ) ;
// If the item is Remediate, fetch its notes and append to description (Requirement 8)
if ( item . workflow _type === 'Remediate' ) {
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/todo-queue/ ${ item . id } /notes ` , { credentials : 'include' } ) ;
if ( res . ok ) {
const notes = await res . json ( ) ;
if ( notes . length > 0 ) {
const notesMap = { [ item . id ] : notes } ;
description = appendRemediationNotes ( description , notesMap ) ;
}
}
} catch ( _e ) { /* best effort — proceed without notes */ }
}
2026-05-22 11:12:45 -06:00
setSingleJiraForm ( {
cve _id : extractFirstCve ( items ) ,
vendor : extractCommonVendor ( items ) ,
summary : generateConsolidatedSummary ( items ) ,
2026-06-08 14:07:59 -06:00
description ,
2026-05-22 11:12:45 -06:00
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 ) ;
} , [ ] ) ;
2026-06-08 14:07:59 -06:00
// ---------------------------------------------------------------------------
// Delete queue item with confirmation for Remediate items with notes
// ---------------------------------------------------------------------------
const initiateDelete = useCallback ( ( item ) => {
const noteCount = localNoteCounts [ item . id ] !== undefined
? localNoteCounts [ item . id ]
: ( item . remediation _notes _count || 0 ) ;
if ( item . workflow _type === 'Remediate' && noteCount > 0 ) {
setDeleteConfirmItem ( { ... item , _noteCount : noteCount } ) ;
} else {
performDelete ( item . id ) ;
}
} , [ localNoteCounts ] ) ;
const performDelete = useCallback ( async ( id ) => {
try {
const res = await fetch ( ` ${ API _BASE } /ivanti/todo-queue/ ${ id } ` , {
method : 'DELETE' ,
credentials : 'include' ,
} ) ;
if ( res . ok ) {
setQueueItems ( ( prev ) => prev . filter ( ( i ) => i . id !== id ) ) ;
}
} catch ( e ) {
console . error ( 'Error deleting queue item:' , e ) ;
}
setDeleteConfirmItem ( null ) ;
} , [ ] ) ;
const cancelDelete = useCallback ( ( ) => {
setDeleteConfirmItem ( null ) ;
} , [ ] ) ;
2026-05-22 11:12:45 -06:00
// ---------------------------------------------------------------------------
// Workflow type color helper
// ---------------------------------------------------------------------------
const getWorkflowColor = ( workflowType ) => {
switch ( workflowType ) {
2026-06-08 14:07:59 -06:00
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' } ;
case 'Remediate' : return { col : '#A855F7' , rgb : '168,85,247' } ;
default : return { col : '#94A3B8' , rgb : '148,163,184' } ;
2026-05-22 11:12:45 -06:00
}
} ;
// ---------------------------------------------------------------------------
// 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 } ` : '' )
: '' ;
2026-06-02 16:08:25 -06:00
const isArcherItem = item . workflow _type === 'Archer' ;
2026-06-08 14:07:59 -06:00
const isRemediateItem = item . workflow _type === 'Remediate' ;
2026-06-02 16:08:25 -06:00
const isTemplatePanelOpen = templatePanelOpenId === item . id ;
2026-06-08 14:07:59 -06:00
const noteCount = localNoteCounts [ item . id ] !== undefined
? localNoteCounts [ item . id ]
: ( item . remediation _notes _count || 0 ) ;
2026-05-27 11:07:32 -06:00
return (
2026-06-02 16:08:25 -06:00
< React . Fragment key = { item . id } >
< div
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 } ` }
/ >
) }
2026-05-27 11:07:32 -06:00
2026-06-02 16:08:25 -06:00
{ /* Finding info */ }
< div style = { { flex : 1 , minWidth : 0 } } >
2026-05-27 11:07:32 -06:00
< div style = { {
fontFamily : 'monospace' ,
2026-06-02 16:08:25 -06:00
fontSize : '0.75rem' ,
fontWeight : 600 ,
color : '#CBD5E1' ,
2026-05-27 11:07:32 -06:00
overflow : 'hidden' ,
textOverflow : 'ellipsis' ,
whiteSpace : 'nowrap' ,
2026-06-02 16:08:25 -06:00
} } title = { item . finding _title || item . finding _id } >
{ item . finding _title || item . finding _id }
2026-05-27 11:07:32 -06:00
< / d i v >
2026-06-02 16:08:25 -06:00
{ 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 >
{ /* Archer Template toggle button (Requirement 5.1) */ }
{ isArcherItem && (
< button
onClick = { ( e ) => { e . stopPropagation ( ) ; toggleTemplatePanel ( item . id ) ; } }
style = { {
display : 'inline-flex' ,
alignItems : 'center' ,
gap : '0.3rem' ,
padding : '0.2rem 0.5rem' ,
borderRadius : '4px' ,
border : isTemplatePanelOpen
? '1px solid rgba(0, 212, 255, 0.5)'
: '1px solid rgba(0, 212, 255, 0.2)' ,
background : isTemplatePanelOpen
? 'rgba(0, 212, 255, 0.15)'
: 'rgba(0, 212, 255, 0.05)' ,
color : isTemplatePanelOpen ? '#00d4ff' : '#7DD3FC' ,
cursor : 'pointer' ,
fontSize : '0.62rem' ,
fontFamily : 'monospace' ,
fontWeight : 600 ,
flexShrink : 0 ,
transition : 'all 0.2s' ,
} }
title = { isTemplatePanelOpen ? 'Hide template selector' : 'Show template selector' }
aria - expanded = { isTemplatePanelOpen }
aria - label = "Toggle template selector"
>
< FileText style = { { width : '11px' , height : '11px' } } / >
{ isTemplatePanelOpen ? 'Hide' : 'Template' }
< / b u t t o n >
2026-05-27 11:07:32 -06:00
) }
2026-06-08 14:07:59 -06:00
{ /* Remediation Notes button (Requirement 5.1, 6.1, 6.2) */ }
{ isRemediateItem && (
< button
onClick = { ( e ) => { e . stopPropagation ( ) ; setRemediationModalItem ( item ) ; } }
style = { {
display : 'inline-flex' ,
alignItems : 'center' ,
gap : '0.3rem' ,
padding : '0.2rem 0.5rem' ,
borderRadius : '4px' ,
border : '1px solid rgba(168, 85, 247, 0.2)' ,
background : 'rgba(168, 85, 247, 0.05)' ,
color : '#C084FC' ,
cursor : 'pointer' ,
fontSize : '0.62rem' ,
fontFamily : 'monospace' ,
fontWeight : 600 ,
flexShrink : 0 ,
transition : 'all 0.2s' ,
position : 'relative' ,
} }
title = "View remediation notes"
aria - label = "Remediation notes"
>
< FileText style = { { width : '11px' , height : '11px' } } / >
Notes
{ noteCount > 0 && (
< span style = { {
fontFamily : 'monospace' ,
fontSize : '0.55rem' ,
fontWeight : 700 ,
color : '#A855F7' ,
background : 'rgba(168, 85, 247, 0.15)' ,
border : '1px solid rgba(168, 85, 247, 0.3)' ,
borderRadius : '999px' ,
padding : '0.05rem 0.3rem' ,
marginLeft : '0.15rem' ,
} } >
{ noteCount > 99 ? '99+' : noteCount }
< / s p a n >
) }
< / b u t t o n >
) }
{ /* Delete button for Remediate items (Requirement 7) */ }
{ isRemediateItem && canWrite ( ) && (
< button
onClick = { ( e ) => { e . stopPropagation ( ) ; initiateDelete ( item ) ; } }
style = { {
background : 'none' ,
border : 'none' ,
cursor : 'pointer' ,
color : '#475569' ,
padding : '0.2rem' ,
borderRadius : '4px' ,
display : 'inline-flex' ,
alignItems : 'center' ,
flexShrink : 0 ,
transition : 'color 0.15s' ,
} }
onMouseEnter = { ( e ) => { e . currentTarget . style . color = '#EF4444' ; } }
onMouseLeave = { ( e ) => { e . currentTarget . style . color = '#475569' ; } }
title = "Delete queue item"
aria - label = "Delete queue item"
>
< Trash2 style = { { width : '13px' , height : '13px' } } / >
< / b u t t o n >
) }
2026-06-02 16:08:25 -06:00
{ /* 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 = { {
2026-05-27 11:07:32 -06:00
fontFamily : 'monospace' ,
fontSize : '0.6rem' ,
fontWeight : 700 ,
2026-06-02 16:08:25 -06:00
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 >
2026-05-27 11:07:32 -06:00
2026-06-02 16:08:25 -06:00
{ /* Vendor */ }
< div style = { {
width : '120px' ,
flexShrink : 0 ,
2026-05-27 11:07:32 -06:00
fontFamily : 'monospace' ,
2026-06-02 16:08:25 -06:00
fontSize : '0.68rem' ,
color : '#94A3B8' ,
overflow : 'hidden' ,
textOverflow : 'ellipsis' ,
whiteSpace : 'nowrap' ,
} } title = { item . vendor } >
{ item . vendor || '—' }
< / d i v >
2026-05-27 11:07:32 -06:00
2026-06-02 16:08:25 -06:00
{ /* 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-27 11:07:32 -06:00
< / d i v >
2026-06-02 16:08:25 -06:00
{ /* Archer Template Selector expandable panel (Requirement 5.1) */ }
{ isArcherItem && isTemplatePanelOpen && (
< div style = { {
marginBottom : '0.5rem' ,
marginLeft : selectionMode ? '1.625rem' : '0' ,
padding : '0.75rem' ,
background : 'linear-gradient(135deg, rgba(22, 33, 62, 0.6), rgba(15, 23, 42, 0.8))' ,
border : '1px solid rgba(0, 212, 255, 0.15)' ,
borderTop : 'none' ,
borderRadius : '0 0 8px 8px' ,
} } >
< TemplateSelector / >
< / d i v >
) }
< / R e a c t . F r a g m e n t >
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 >
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
{ ( ( ) => {
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
< / b u t t o n >
) : null ;
} ) ( ) }
2026-05-22 11:12:45 -06:00
< 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 }
/ >
) }
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
{ /* 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 }
/ >
2026-06-08 14:07:59 -06:00
{ /* Remediation Notes Modal */ }
{ remediationModalItem && (
< RemediationModal
item = { remediationModalItem }
onClose = { ( ) => setRemediationModalItem ( null ) }
onNoteAdded = { ( ) => {
setLocalNoteCounts ( ( prev ) => ( {
... prev ,
[ remediationModalItem . id ] : ( prev [ remediationModalItem . id ] !== undefined
? prev [ remediationModalItem . id ]
: ( remediationModalItem . remediation _notes _count || 0 ) ) + 1 ,
} ) ) ;
} }
/ >
) }
{ /* Delete Confirmation Dialog (Requirement 7) */ }
{ deleteConfirmItem && (
< div style = { STYLES . modal } >
< div style = { STYLES . modalBackdrop } onClick = { cancelDelete } / >
< div style = { { ... STYLES . modalContent , maxWidth : '400px' } } >
< div style = { { display : 'flex' , alignItems : 'center' , gap : '0.5rem' , marginBottom : '1rem' } } >
< AlertCircle style = { { width : '20px' , height : '20px' , color : '#EF4444' } } / >
< span style = { { fontFamily : 'monospace' , fontSize : '0.8rem' , fontWeight : 700 , color : '#F8FAFC' } } >
Delete Queue Item
< / s p a n >
< / d i v >
< p style = { { fontFamily : 'monospace' , fontSize : '0.75rem' , color : '#CBD5E1' , lineHeight : 1.6 , margin : '0 0 1rem 0' } } >
This item has < span style = { { color : '#A855F7' , fontWeight : 700 } } > { deleteConfirmItem . _noteCount } < / s p a n > r e m e d i a t i o n n o t e { d e l e t e C o n f i r m I t e m . _ n o t e C o u n t ! = = 1 ? ' s ' : ' ' } .
Deleting this item will < span style = { { color : '#EF4444' , fontWeight : 600 } } > permanently delete < / s p a n > a l l a s s o c i a t e d r e m e d i a t i o n n o t e s .
< / p >
< div style = { { display : 'flex' , gap : '0.5rem' , justifyContent : 'flex-end' } } >
< button
onClick = { cancelDelete }
style = { STYLES . btnCancel }
>
Cancel
< / b u t t o n >
< button
onClick = { ( ) => performDelete ( deleteConfirmItem . id ) }
style = { {
padding : '0.5rem 1rem' ,
borderRadius : '8px' ,
border : '1px solid rgba(239, 68, 68, 0.4)' ,
background : 'rgba(239, 68, 68, 0.15)' ,
color : '#FCA5A5' ,
cursor : 'pointer' ,
fontSize : '0.8rem' ,
fontWeight : 600 ,
display : 'inline-flex' ,
alignItems : 'center' ,
gap : '0.4rem' ,
transition : 'all 0.2s' ,
} }
>
< Trash2 style = { { width : '14px' , height : '14px' } } / >
Delete
< / b u t t o n >
< / d i v >
< / d i v >
< / d i v >
) }
2026-05-22 11:12:45 -06:00
< / d i v >
) ;
}