diff --git a/frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js b/frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js new file mode 100644 index 0000000..f11b74a --- /dev/null +++ b/frontend/src/__tests__/queue-grouping-count-accuracy.property.test.js @@ -0,0 +1,88 @@ +/** + * Property-Based Test: Section Header Count Accuracy + * + * Feature: queue-collapsible-sections, Property 4: Section Header Count Accuracy + * **Validates: Requirements 3.1, 3.2** + * + * For every section in the grouped output, the items array length equals the count + * that would be displayed in the header. The sum of all section item counts equals + * the total input array length. + */ +import fc from 'fast-check'; + +// Replicate the grouping logic inline (utility file not yet extracted) +function groupQueueItems(visibleItems) { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const inventoryItems = []; + const vendorMap = new Map(); + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + const sections = []; + if (inventoryItems.length > 0) { + sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems }); + } + const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [vendor, items] of sortedVendors) { + sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items }); + } + return sections; +} + +// Generator for queue items with realistic workflow types and vendor names +const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'); +const vendorArb = fc.oneof( + fc.constantFrom('Microsoft', 'Adobe', 'Cisco', 'Oracle', 'VMware', 'Apple'), + fc.constant(''), + fc.constant(null), + fc.constant(undefined) +); + +const queueItemArb = fc.record({ + id: fc.uuid(), + workflow_type: workflowTypeArb, + vendor: vendorArb, + hostname: fc.string({ minLength: 1, maxLength: 20 }), + status: fc.constant('pending'), +}); + +const queueItemsArb = fc.array(queueItemArb, { minLength: 1, maxLength: 50 }); + +describe('Queue Grouping — Property 4: Section Header Count Accuracy', () => { + it('each section items.length is a positive integer (the displayed count)', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + for (const section of sections) { + // items.length is the count that would be displayed in the header + expect(Number.isInteger(section.items.length)).toBe(true); + expect(section.items.length).toBeGreaterThan(0); + } + }), + { numRuns: 200 } + ); + }); + + it('sum of all section item counts equals total input array length', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + const totalItemsInSections = sections.reduce( + (sum, section) => sum + section.items.length, + 0 + ); + + expect(totalItemsInSections).toBe(items.length); + }), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-grouping-empty-sections.property.test.js b/frontend/src/__tests__/queue-grouping-empty-sections.property.test.js new file mode 100644 index 0000000..d61e8a2 --- /dev/null +++ b/frontend/src/__tests__/queue-grouping-empty-sections.property.test.js @@ -0,0 +1,111 @@ +/** + * Property-Based Test: Empty Section Omission + * + * Feature: queue-collapsible-sections, Property 3: Empty Section Omission + * **Validates: Requirements 1.6, 1.7** + * + * For any array of queue items (including empty arrays), the grouped output + * contains no sections with zero items. If no CARD/GRANITE/DECOM items exist + * in the input, no section with type 'inventory' appears in the output. + */ +import fc from 'fast-check'; + +// Inline the grouping logic since the utility file may not exist yet +function groupQueueItems(visibleItems) { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const inventoryItems = []; + const vendorMap = new Map(); + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + const sections = []; + if (inventoryItems.length > 0) { + sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems }); + } + const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [vendor, items] of sortedVendors) { + sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items }); + } + return sections; +} + +// Generator for queue items with various workflow types and vendors +const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'); +const vendorArb = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/) +); + +const queueItemArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: workflowTypeArb, + vendor: vendorArb, + hostname: fc.stringMatching(/^[a-z]{3,10}$/), + status: fc.constant('pending'), +}); + +const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 }); + +describe('Queue Grouping — Property 3: Empty Section Omission', () => { + it('no section in the output has zero items', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + for (const section of sections) { + expect(section.items.length).toBeGreaterThan(0); + } + }), + { numRuns: 200 } + ); + }); + + it('if no inventory-type items exist, no inventory section appears', () => { + // Generate arrays that explicitly exclude inventory types + const nonInventoryTypeArb = fc.constantFrom('FP', 'Archer'); + const nonInventoryItemArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: nonInventoryTypeArb, + vendor: vendorArb, + hostname: fc.stringMatching(/^[a-z]{3,10}$/), + status: fc.constant('pending'), + }); + const nonInventoryItemsArb = fc.array(nonInventoryItemArb, { minLength: 0, maxLength: 50 }); + + fc.assert( + fc.property(nonInventoryItemsArb, (items) => { + const sections = groupQueueItems(items); + const inventorySection = sections.find(s => s.type === 'inventory'); + expect(inventorySection).toBeUndefined(); + }), + { numRuns: 200 } + ); + }); + + it('if no CARD/GRANITE/DECOM items exist in a mixed array, no inventory section appears', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const hasInventoryItems = items.some(item => INVENTORY_TYPES.has(item.workflow_type)); + const sections = groupQueueItems(items); + const inventorySection = sections.find(s => s.type === 'inventory'); + + if (!hasInventoryItems) { + expect(inventorySection).toBeUndefined(); + } else { + expect(inventorySection).toBeDefined(); + expect(inventorySection.items.length).toBeGreaterThan(0); + } + }), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-grouping-ordering.property.test.js b/frontend/src/__tests__/queue-grouping-ordering.property.test.js new file mode 100644 index 0000000..c2743b9 --- /dev/null +++ b/frontend/src/__tests__/queue-grouping-ordering.property.test.js @@ -0,0 +1,92 @@ +/** + * Property-Based Test: Section Ordering + * + * Feature: queue-collapsible-sections, Property 2: Section Ordering + * **Validates: Requirements 1.3, 1.4** + * + * For any array of queue items, the Inventory section (if present) is always + * the first element, and all vendor sections are sorted alphabetically by label. + */ +import fc from 'fast-check'; + +// Inline grouping logic (mirrors the implementation in IvantiTodoQueuePage) +function groupQueueItems(visibleItems) { + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + const inventoryItems = []; + const vendorMap = new Map(); + for (const item of visibleItems) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + inventoryItems.push(item); + } else { + const vendor = item.vendor?.trim() || 'Unknown'; + if (!vendorMap.has(vendor)) vendorMap.set(vendor, []); + vendorMap.get(vendor).push(item); + } + } + const sections = []; + if (inventoryItems.length > 0) { + sections.push({ key: 'inventory', label: 'Inventory', type: 'inventory', items: inventoryItems }); + } + const sortedVendors = [...vendorMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + for (const [vendor, items] of sortedVendors) { + sections.push({ key: `vendor:${vendor}`, label: vendor, type: 'vendor', items }); + } + return sections; +} + +// Generator for queue items with random workflow types and vendors +const workflowTypeArb = fc.constantFrom('CARD', 'GRANITE', 'DECOM', 'FP', 'Archer'); + +const vendorArb = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,14}$/) +); + +const queueItemArb = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: workflowTypeArb, + vendor: vendorArb, + hostname: fc.string({ minLength: 1, maxLength: 20 }), + status: fc.constant('pending'), +}); + +const queueItemsArb = fc.array(queueItemArb, { minLength: 0, maxLength: 50 }); + +describe('Queue Grouping — Property 2: Section Ordering', () => { + it('Inventory section (if present) is always the first element', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + const inventoryIndex = sections.findIndex(s => s.type === 'inventory'); + + // If inventory section exists, it must be at index 0 + if (inventoryIndex !== -1) { + expect(inventoryIndex).toBe(0); + } + }), + { numRuns: 500 } + ); + }); + + it('Vendor sections are sorted alphabetically by label', () => { + fc.assert( + fc.property(queueItemsArb, (items) => { + const sections = groupQueueItems(items); + + const vendorSections = sections.filter(s => s.type === 'vendor'); + + // Each consecutive pair of vendor sections must be in alphabetical order + for (let i = 1; i < vendorSections.length; i++) { + const prev = vendorSections[i - 1].label; + const curr = vendorSections[i].label; + expect(prev.localeCompare(curr)).toBeLessThanOrEqual(0); + } + }), + { numRuns: 500 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-grouping.property.test.js b/frontend/src/__tests__/queue-grouping.property.test.js new file mode 100644 index 0000000..95d706b --- /dev/null +++ b/frontend/src/__tests__/queue-grouping.property.test.js @@ -0,0 +1,135 @@ +/** + * Property-Based Test: Grouping Correctness + * + * Feature: queue-collapsible-sections, Property 1: Grouping Correctness + * **Validates: Requirements 1.1, 1.2, 1.5** + * + * For any array of visible queue items, every item with workflow_type CARD, + * GRANITE, or DECOM appears in the Inventory section; every FP/Archer item + * appears in the vendor section matching its vendor field (or "Unknown" if + * null/empty); no item appears in more than one section; and total items + * across all sections equals input length. + */ +import fc from 'fast-check'; +import { groupQueueItems } from '../utils/queueGrouping'; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const INVENTORY_TYPES = ['CARD', 'GRANITE', 'DECOM']; +const VENDOR_TYPES = ['FP', 'Archer']; +const ALL_WORKFLOW_TYPES = [...INVENTORY_TYPES, ...VENDOR_TYPES]; + +// Generate vendor strings including edge cases: null, undefined, empty, whitespace-only, normal strings +const vendorArbitrary = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.constant(''), + fc.constant(' '), + fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,19}$/) +); + +// Generate a single queue item with a random workflow_type and vendor +const queueItemArbitrary = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: fc.constantFrom(...ALL_WORKFLOW_TYPES), + vendor: vendorArbitrary, + status: fc.constant('pending'), + hostname: fc.string({ minLength: 1, maxLength: 20 }), +}); + +// Generate arrays of queue items (0 to 50 items) +const queueItemsArbitrary = fc.array(queueItemArbitrary, { minLength: 0, maxLength: 50 }); + +// --------------------------------------------------------------------------- +// Property Test +// --------------------------------------------------------------------------- + +describe('Queue Grouping — Property 1: Grouping Correctness', () => { + it('every CARD/GRANITE/DECOM item appears in Inventory section', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + const inventorySection = sections.find(s => s.key === 'inventory'); + const inventoryItems = inventorySection ? inventorySection.items : []; + + const expectedInventoryItems = items.filter(item => + INVENTORY_TYPES.includes(item.workflow_type) + ); + + // Every inventory-type item must be in the inventory section + for (const item of expectedInventoryItems) { + expect(inventoryItems).toContain(item); + } + + // Inventory section should contain exactly the inventory-type items + expect(inventoryItems.length).toBe(expectedInventoryItems.length); + }), + { numRuns: 200 } + ); + }); + + it('every FP/Archer item appears in a vendor section matching its vendor field (or "Unknown")', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + + const vendorSections = sections.filter(s => s.type === 'vendor'); + const allVendorItems = vendorSections.flatMap(s => s.items); + + const expectedVendorItems = items.filter(item => + VENDOR_TYPES.includes(item.workflow_type) + ); + + // Every vendor-type item must appear in a vendor section + for (const item of expectedVendorItems) { + expect(allVendorItems).toContain(item); + + // Determine expected vendor key + const expectedVendor = item.vendor?.trim() || 'Unknown'; + const expectedKey = `vendor:${expectedVendor}`; + const matchingSection = sections.find(s => s.key === expectedKey); + + expect(matchingSection).toBeDefined(); + expect(matchingSection.items).toContain(item); + } + + // Vendor sections should contain exactly the vendor-type items + expect(allVendorItems.length).toBe(expectedVendorItems.length); + }), + { numRuns: 200 } + ); + }); + + it('no item appears in more than one section', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + + // Collect all items across all sections + const allSectionItems = sections.flatMap(s => s.items); + + // Use a Set to check for duplicates (by reference) + const seen = new Set(); + for (const item of allSectionItems) { + expect(seen.has(item)).toBe(false); + seen.add(item); + } + }), + { numRuns: 200 } + ); + }); + + it('total items across all sections equals input length', () => { + fc.assert( + fc.property(queueItemsArbitrary, (items) => { + const sections = groupQueueItems(items); + const totalInSections = sections.reduce((sum, s) => sum + s.items.length, 0); + + expect(totalInSections).toBe(items.length); + }), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/__tests__/queue-selection-independence.property.test.js b/frontend/src/__tests__/queue-selection-independence.property.test.js new file mode 100644 index 0000000..d6d265d --- /dev/null +++ b/frontend/src/__tests__/queue-selection-independence.property.test.js @@ -0,0 +1,213 @@ +/** + * Property-Based Test: Selection Independence from Collapse State + * + * Feature: queue-collapsible-sections, Property 5: Selection Independence from Collapse State + * **Validates: Requirements 4.2, 4.4, 4.5** + * + * For any combination of selected items and collapse state, the set of selected + * item IDs remains unchanged when sections are collapsed or expanded. Select All + * always covers all visible items regardless of which sections are collapsed. + */ +import fc from 'fast-check'; + +// --- Pure logic under test (mirrors IvantiTodoQueuePage behavior) --- + +/** + * Toggle a section's collapse state. Only modifies collapsedSections, + * never touches selectedIds. + */ +function toggleSection(collapsedSections, sectionKey) { + return { ...collapsedSections, [sectionKey]: !collapsedSections[sectionKey] }; +} + +/** + * Select All — always selects all visible items regardless of collapse state. + * The visibleItems array contains ALL pending items, not just those in expanded sections. + */ +function selectAll(visibleItems) { + return new Set(visibleItems.map(item => item.id)); +} + +// --- Generators --- + +const WORKFLOW_TYPES = ['CARD', 'GRANITE', 'DECOM', 'FP', 'Archer']; +const VENDORS = ['Microsoft', 'Adobe', 'Cisco', 'Unknown', 'Oracle', 'VMware']; + +const queueItemArbitrary = fc.record({ + id: fc.integer({ min: 1, max: 100000 }), + workflow_type: fc.constantFrom(...WORKFLOW_TYPES), + vendor: fc.constantFrom(...VENDORS), + status: fc.constant('pending'), +}); + +// Generate arrays of queue items with unique IDs +const queueItemsArbitrary = fc + .array(queueItemArbitrary, { minLength: 1, maxLength: 50 }) + .map(items => { + // Ensure unique IDs + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + }) + .filter(items => items.length > 0); + +// Generate a subset of item IDs as the selected set +const selectedIdsArbitrary = (items) => + fc.subarray(items.map(i => i.id), { minLength: 0 }).map(ids => new Set(ids)); + +// Generate section keys that would exist for a given set of items +const sectionKeysForItems = (items) => { + const keys = new Set(); + const INVENTORY_TYPES = new Set(['CARD', 'GRANITE', 'DECOM']); + let hasInventory = false; + for (const item of items) { + if (INVENTORY_TYPES.has(item.workflow_type)) { + hasInventory = true; + } else { + keys.add(`vendor:${item.vendor || 'Unknown'}`); + } + } + if (hasInventory) keys.add('inventory'); + return [...keys]; +}; + +// Generate arbitrary collapse state for given section keys +const collapsedSectionsArbitrary = (sectionKeys) => + fc.record( + Object.fromEntries(sectionKeys.map(key => [key, fc.boolean()])), + { withDeletedKeys: true } + ); + +// --- Tests --- + +describe('Property 5: Selection Independence from Collapse State', () => { + it('toggling collapse state does not alter the set of selected item IDs', () => { + fc.assert( + fc.property( + queueItemsArbitrary.chain(items => + fc.tuple( + fc.constant(items), + selectedIdsArbitrary(items), + fc.constant(sectionKeysForItems(items)) + ) + ).chain(([items, selectedIds, sectionKeys]) => + fc.tuple( + fc.constant(items), + fc.constant(selectedIds), + sectionKeys.length > 0 + ? collapsedSectionsArbitrary(sectionKeys) + : fc.constant({}), + sectionKeys.length > 0 + ? fc.array(fc.constantFrom(...sectionKeys), { minLength: 1, maxLength: 10 }) + : fc.constant([]) + ) + ), + ([items, selectedIds, initialCollapsed, toggleSequence]) => { + // Record the original selected IDs + const originalSelectedIds = new Set(selectedIds); + + // Apply a sequence of toggleSection calls + let currentCollapsed = { ...initialCollapsed }; + for (const sectionKey of toggleSequence) { + currentCollapsed = toggleSection(currentCollapsed, sectionKey); + } + + // The selectedIds set must remain completely unchanged + // (toggleSection only modifies collapsedSections, never selectedIds) + expect(selectedIds).toEqual(originalSelectedIds); + expect(selectedIds.size).toBe(originalSelectedIds.size); + for (const id of originalSelectedIds) { + expect(selectedIds.has(id)).toBe(true); + } + } + ), + { numRuns: 200 } + ); + }); + + it('Select All always covers all visible items regardless of collapse state', () => { + fc.assert( + fc.property( + queueItemsArbitrary.chain(items => + fc.tuple( + fc.constant(items), + fc.constant(sectionKeysForItems(items)) + ) + ).chain(([items, sectionKeys]) => + fc.tuple( + fc.constant(items), + sectionKeys.length > 0 + ? collapsedSectionsArbitrary(sectionKeys) + : fc.constant({}) + ) + ), + ([visibleItems, collapsedSections]) => { + // Regardless of which sections are collapsed, selectAll operates + // on the full visibleItems array (all pending items) + const allSelectedIds = selectAll(visibleItems); + + // Every visible item must be in the selected set + for (const item of visibleItems) { + expect(allSelectedIds.has(item.id)).toBe(true); + } + + // The selected set size must equal the number of visible items + expect(allSelectedIds.size).toBe(visibleItems.length); + + // Verify this holds even after toggling all sections to collapsed + let fullyCollapsed = { ...collapsedSections }; + const sectionKeys = Object.keys(collapsedSections); + for (const key of sectionKeys) { + fullyCollapsed = toggleSection(fullyCollapsed, key); + } + + // selectAll still returns all visible items — collapse state is irrelevant + const afterCollapseSelectAll = selectAll(visibleItems); + expect(afterCollapseSelectAll).toEqual(allSelectedIds); + expect(afterCollapseSelectAll.size).toBe(visibleItems.length); + } + ), + { numRuns: 200 } + ); + }); + + it('selection count always equals total selected items across all sections regardless of collapse', () => { + fc.assert( + fc.property( + queueItemsArbitrary.chain(items => + fc.tuple( + fc.constant(items), + selectedIdsArbitrary(items), + fc.constant(sectionKeysForItems(items)) + ) + ).chain(([items, selectedIds, sectionKeys]) => + fc.tuple( + fc.constant(items), + fc.constant(selectedIds), + sectionKeys.length > 0 + ? collapsedSectionsArbitrary(sectionKeys) + : fc.constant({}) + ) + ), + ([_items, selectedIds, collapsedSections]) => { + // The selection count (selectedIds.size) is independent of collapse state. + // Changing collapse state should never change the count. + const countBefore = selectedIds.size; + + // Toggle every section + let currentCollapsed = { ...collapsedSections }; + const sectionKeys = Object.keys(collapsedSections); + for (const key of sectionKeys) { + currentCollapsed = toggleSection(currentCollapsed, key); + // After each toggle, selection count must remain the same + expect(selectedIds.size).toBe(countBefore); + } + } + ), + { numRuns: 200 } + ); + }); +}); diff --git a/frontend/src/components/pages/IvantiTodoQueuePage.js b/frontend/src/components/pages/IvantiTodoQueuePage.js index 9fa8d7c..9ca58ff 100644 --- a/frontend/src/components/pages/IvantiTodoQueuePage.js +++ b/frontend/src/components/pages/IvantiTodoQueuePage.js @@ -1,8 +1,9 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle } from 'lucide-react'; +import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import ConsolidationModal from '../ConsolidationModal'; import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation'; +import { groupQueueItems } from '../../utils/queueGrouping'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -234,6 +235,45 @@ const STYLES = { 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', + }, }; // --------------------------------------------------------------------------- @@ -265,6 +305,9 @@ export default function IvantiTodoQueuePage() { 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 // --------------------------------------------------------------------------- @@ -323,6 +366,21 @@ export default function IvantiTodoQueuePage() { 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 @@ -627,158 +685,190 @@ export default function IvantiTodoQueuePage() { Host - {/* Queue item rows */} - {visibleItems.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}` : '') - : ''; + {/* Grouped sections with collapsible headers (Requirements 2.1, 2.3–2.6, 3.1, 3.2) */} + {groupedSections.map((section) => { + const isCollapsed = !!collapsedSections[section.key]; return ( -