2026-05-22 11:12:45 -06:00
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
import { X, AlertCircle, Loader, FileText } from 'lucide-react';
|
|
|
|
|
import {
|
|
|
|
|
generateConsolidatedSummary,
|
|
|
|
|
generateConsolidatedDescription,
|
|
|
|
|
extractFirstCve,
|
|
|
|
|
extractCommonVendor,
|
2026-06-08 14:59:48 -06:00
|
|
|
appendRemediationNotes,
|
2026-05-22 11:12:45 -06:00
|
|
|
} from '../utils/jiraConsolidation';
|
|
|
|
|
|
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Styles — dark theme, monospace fonts, #0EA5E9 accent, gradient backgrounds
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const STYLES = {
|
|
|
|
|
overlay: {
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
inset: 0,
|
|
|
|
|
zIndex: 100,
|
|
|
|
|
background: 'rgba(10, 14, 39, 0.95)',
|
|
|
|
|
backdropFilter: 'blur(8px)',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
padding: '1rem',
|
|
|
|
|
},
|
|
|
|
|
modal: {
|
|
|
|
|
position: 'relative',
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98) 0%, rgba(15, 23, 42, 0.99) 100%)',
|
|
|
|
|
border: '1px solid rgba(14, 165, 233, 0.25)',
|
|
|
|
|
borderRadius: '16px',
|
|
|
|
|
padding: '2rem',
|
|
|
|
|
width: '90%',
|
|
|
|
|
maxWidth: '640px',
|
|
|
|
|
maxHeight: '85vh',
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.7), 0 0 30px rgba(14,165,233,0.08)',
|
|
|
|
|
},
|
|
|
|
|
header: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
marginBottom: '1.25rem',
|
|
|
|
|
},
|
|
|
|
|
title: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.9rem',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
color: '#0EA5E9',
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '0.08em',
|
|
|
|
|
},
|
|
|
|
|
subtitle: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.72rem',
|
|
|
|
|
color: '#94A3B8',
|
|
|
|
|
marginTop: '0.25rem',
|
|
|
|
|
},
|
|
|
|
|
closeBtn: {
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
border: 'none',
|
|
|
|
|
color: '#64748B',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
padding: '0.25rem',
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
},
|
|
|
|
|
section: {
|
|
|
|
|
marginBottom: '1.25rem',
|
|
|
|
|
},
|
|
|
|
|
label: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.68rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: '#7DD3FC',
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '0.1em',
|
|
|
|
|
marginBottom: '0.4rem',
|
|
|
|
|
display: 'block',
|
|
|
|
|
},
|
|
|
|
|
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.82rem',
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
width: '100%',
|
|
|
|
|
outline: 'none',
|
|
|
|
|
boxSizing: 'border-box',
|
|
|
|
|
transition: 'border-color 0.2s',
|
|
|
|
|
},
|
|
|
|
|
inputError: {
|
|
|
|
|
borderColor: 'rgba(239, 68, 68, 0.6)',
|
|
|
|
|
},
|
|
|
|
|
textarea: {
|
|
|
|
|
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.78rem',
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
width: '100%',
|
|
|
|
|
minHeight: '120px',
|
|
|
|
|
resize: 'vertical',
|
|
|
|
|
outline: 'none',
|
|
|
|
|
boxSizing: 'border-box',
|
|
|
|
|
transition: 'border-color 0.2s',
|
|
|
|
|
},
|
|
|
|
|
readOnlyBadge: {
|
|
|
|
|
display: 'inline-block',
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
color: '#0EA5E9',
|
|
|
|
|
background: 'rgba(14, 165, 233, 0.1)',
|
|
|
|
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
padding: '0.25rem 0.6rem',
|
|
|
|
|
},
|
|
|
|
|
previewList: {
|
|
|
|
|
maxHeight: '160px',
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
border: '1px solid rgba(14, 165, 233, 0.15)',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
background: 'rgba(15, 23, 42, 0.6)',
|
|
|
|
|
},
|
|
|
|
|
previewItem: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
padding: '0.4rem 0.6rem',
|
|
|
|
|
borderBottom: '1px solid rgba(255, 255, 255, 0.04)',
|
|
|
|
|
},
|
|
|
|
|
previewItemText: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
},
|
|
|
|
|
previewTitle: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.72rem',
|
|
|
|
|
color: '#CBD5E1',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
},
|
|
|
|
|
previewHost: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.62rem',
|
|
|
|
|
color: '#64748B',
|
|
|
|
|
marginTop: '1px',
|
|
|
|
|
},
|
|
|
|
|
removeBtn: {
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
color: '#EF4444',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
padding: '0.15rem 0.3rem',
|
|
|
|
|
marginLeft: '0.5rem',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
transition: 'all 0.2s',
|
|
|
|
|
},
|
|
|
|
|
errorMsg: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
color: '#EF4444',
|
|
|
|
|
marginTop: '0.3rem',
|
|
|
|
|
},
|
|
|
|
|
warningMsg: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
color: '#F59E0B',
|
|
|
|
|
background: 'rgba(245, 158, 11, 0.08)',
|
|
|
|
|
border: '1px solid rgba(245, 158, 11, 0.2)',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '0.5rem 0.75rem',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.4rem',
|
|
|
|
|
},
|
|
|
|
|
apiError: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.72rem',
|
|
|
|
|
color: '#EF4444',
|
|
|
|
|
background: 'rgba(239, 68, 68, 0.08)',
|
|
|
|
|
border: '1px solid rgba(239, 68, 68, 0.25)',
|
|
|
|
|
borderRadius: '6px',
|
|
|
|
|
padding: '0.5rem 0.75rem',
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '0.4rem',
|
|
|
|
|
},
|
|
|
|
|
actions: {
|
|
|
|
|
display: 'flex',
|
|
|
|
|
gap: '0.75rem',
|
|
|
|
|
marginTop: '1.5rem',
|
|
|
|
|
},
|
|
|
|
|
cancelBtn: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
padding: '0.625rem',
|
|
|
|
|
background: 'transparent',
|
|
|
|
|
border: '1px solid rgba(100, 116, 139, 0.4)',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
color: '#94A3B8',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.78rem',
|
|
|
|
|
transition: 'all 0.2s ease',
|
|
|
|
|
},
|
|
|
|
|
submitBtn: {
|
|
|
|
|
flex: 1.5,
|
|
|
|
|
padding: '0.625rem',
|
|
|
|
|
background: 'rgba(14, 165, 233, 0.1)',
|
|
|
|
|
border: '1px solid #0EA5E9',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
color: '#0EA5E9',
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.78rem',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
letterSpacing: '0.05em',
|
|
|
|
|
transition: 'all 0.2s ease',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
gap: '0.4rem',
|
|
|
|
|
},
|
|
|
|
|
submitBtnDisabled: {
|
|
|
|
|
opacity: 0.4,
|
|
|
|
|
cursor: 'not-allowed',
|
|
|
|
|
},
|
|
|
|
|
charCount: {
|
|
|
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
|
|
|
fontSize: '0.62rem',
|
|
|
|
|
color: '#64748B',
|
|
|
|
|
textAlign: 'right',
|
|
|
|
|
marginTop: '0.2rem',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ConsolidationModal — Creates a single Jira ticket from multiple selected
|
|
|
|
|
* Ivanti queue items. Pre-populates summary, description, CVE, and vendor
|
|
|
|
|
* using aggregation functions.
|
|
|
|
|
*
|
|
|
|
|
* Props:
|
|
|
|
|
* items {Array} — The selected queue items (full objects)
|
|
|
|
|
* onClose {Function} — Close handler
|
|
|
|
|
* onSuccess {Function} — Called with created ticket data on success
|
|
|
|
|
*/
|
|
|
|
|
export default function ConsolidationModal({ items, onClose, onSuccess }) {
|
|
|
|
|
// Internal state — copy of items that can be modified (items removed)
|
|
|
|
|
const [selectedItems, setSelectedItems] = useState(items);
|
|
|
|
|
|
|
|
|
|
// Form fields
|
|
|
|
|
const [summary, setSummary] = useState('');
|
|
|
|
|
const [description, setDescription] = useState('');
|
|
|
|
|
const [cveId, setCveId] = useState('');
|
|
|
|
|
const [vendor, setVendor] = useState('');
|
|
|
|
|
|
|
|
|
|
// Locked source context
|
|
|
|
|
const sourceContext = 'ivanti_queue';
|
|
|
|
|
|
|
|
|
|
// UI state
|
|
|
|
|
const [summaryError, setSummaryError] = useState(null);
|
|
|
|
|
const [apiError, setApiError] = useState(null);
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-22 11:19:46 -06:00
|
|
|
// Initialize form fields on mount from the original items prop.
|
|
|
|
|
// Uses items (prop) not selectedItems (state) to avoid re-triggering on removal.
|
2026-05-22 11:12:45 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
useEffect(() => {
|
2026-05-22 11:19:46 -06:00
|
|
|
if (items.length >= 2) {
|
|
|
|
|
setSummary(generateConsolidatedSummary(items));
|
|
|
|
|
setCveId(extractFirstCve(items));
|
|
|
|
|
setVendor(extractCommonVendor(items));
|
2026-06-08 14:59:48 -06:00
|
|
|
|
|
|
|
|
// Build description, appending remediation notes for Remediate items
|
|
|
|
|
const baseDescription = generateConsolidatedDescription(items);
|
|
|
|
|
const remediateItems = items.filter(i => i.workflow_type === 'Remediate');
|
|
|
|
|
if (remediateItems.length > 0) {
|
|
|
|
|
Promise.all(
|
|
|
|
|
remediateItems.map(item =>
|
|
|
|
|
fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, { credentials: 'include' })
|
|
|
|
|
.then(r => r.ok ? r.json() : [])
|
|
|
|
|
.catch(() => [])
|
|
|
|
|
)
|
|
|
|
|
).then(results => {
|
|
|
|
|
const notesMap = {};
|
|
|
|
|
remediateItems.forEach((item, idx) => {
|
|
|
|
|
if (results[idx] && results[idx].length > 0) {
|
|
|
|
|
notesMap[item.id] = results[idx];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
setDescription(appendRemediationNotes(baseDescription, notesMap));
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
setDescription(baseDescription);
|
|
|
|
|
}
|
2026-05-22 11:12:45 -06:00
|
|
|
}
|
2026-05-22 11:19:46 -06:00
|
|
|
}, [items]);
|
2026-05-22 11:12:45 -06:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Remove an item from the selection (minimum 2 required)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const removeItem = useCallback((itemId) => {
|
|
|
|
|
setSelectedItems((prev) => prev.filter((i) => i.id !== itemId));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Escape key handler
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKey = (e) => {
|
|
|
|
|
if (e.key === 'Escape') onClose?.();
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('keydown', handleKey);
|
|
|
|
|
return () => document.removeEventListener('keydown', handleKey);
|
|
|
|
|
}, [onClose]);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Form validation
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const canSubmit = selectedItems.length >= 2 && summary.trim().length > 0 && !submitting;
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Submit handler
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
// Validate summary
|
|
|
|
|
if (!summary.trim()) {
|
|
|
|
|
setSummaryError('Summary is required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (summary.length > 255) {
|
|
|
|
|
setSummaryError('Summary must be 255 characters or fewer.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSummaryError(null);
|
|
|
|
|
setApiError(null);
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Step 1: Create the Jira ticket
|
|
|
|
|
const createPayload = {
|
|
|
|
|
summary: summary.trim(),
|
|
|
|
|
description,
|
|
|
|
|
cve_id: cveId.trim() || null,
|
|
|
|
|
vendor: vendor.trim() || null,
|
|
|
|
|
source_context: sourceContext,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const createRes = await fetch(`${API_BASE}/jira-tickets/create-in-jira`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify(createPayload),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const createData = await createRes.json();
|
|
|
|
|
|
|
|
|
|
if (!createRes.ok && createRes.status !== 207) {
|
|
|
|
|
throw new Error(createData.error || `Failed to create Jira ticket (HTTP ${createRes.status})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ticketId = createData.id;
|
|
|
|
|
|
|
|
|
|
// Step 2: Link queue items to the ticket via junction endpoint
|
|
|
|
|
const linkRes = await fetch(`${API_BASE}/jira-tickets/${ticketId}/queue-items`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ queue_item_ids: selectedItems.map((i) => i.id) }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!linkRes.ok) {
|
|
|
|
|
const linkData = await linkRes.json();
|
|
|
|
|
// Ticket was created but linking failed — partial success
|
|
|
|
|
console.warn('Junction link failed:', linkData.error || linkRes.status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Success — close modal and notify parent
|
|
|
|
|
onSuccess?.(createData);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setApiError(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Render
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="consolidation-modal-title"
|
|
|
|
|
style={STYLES.overlay}
|
|
|
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose?.(); }}
|
|
|
|
|
>
|
|
|
|
|
<div style={STYLES.modal}>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div style={STYLES.header}>
|
|
|
|
|
<div>
|
|
|
|
|
<div id="consolidation-modal-title" style={STYLES.title}>
|
|
|
|
|
<FileText style={{ width: '16px', height: '16px', display: 'inline', verticalAlign: 'middle', marginRight: '0.4rem' }} />
|
|
|
|
|
Create Consolidated Jira Ticket
|
|
|
|
|
</div>
|
|
|
|
|
<div style={STYLES.subtitle}>
|
|
|
|
|
{selectedItems.length} item{selectedItems.length !== 1 ? 's' : ''} selected for consolidation
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
style={STYLES.closeBtn}
|
|
|
|
|
title="Close"
|
|
|
|
|
aria-label="Close modal"
|
|
|
|
|
>
|
|
|
|
|
<X style={{ width: '18px', height: '18px' }} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Minimum items warning */}
|
|
|
|
|
{selectedItems.length < 2 && (
|
|
|
|
|
<div style={STYLES.warningMsg}>
|
|
|
|
|
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
|
|
|
|
|
At least 2 items are required for consolidation. Add more items or close this modal.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* API error */}
|
|
|
|
|
{apiError && (
|
|
|
|
|
<div style={STYLES.apiError}>
|
|
|
|
|
<AlertCircle style={{ width: '14px', height: '14px', flexShrink: 0 }} />
|
|
|
|
|
{apiError}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Selected items preview list */}
|
|
|
|
|
<div style={STYLES.section}>
|
|
|
|
|
<label style={STYLES.label}>Selected Items</label>
|
|
|
|
|
<div style={STYLES.previewList}>
|
|
|
|
|
{selectedItems.map((item) => (
|
|
|
|
|
<div key={item.id} style={STYLES.previewItem}>
|
|
|
|
|
<div style={STYLES.previewItemText}>
|
|
|
|
|
<div style={STYLES.previewTitle} title={item.finding_title}>
|
|
|
|
|
{item.finding_title || item.finding_id || 'Untitled'}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={STYLES.previewHost}>
|
|
|
|
|
{item.hostname || 'No hostname'}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => removeItem(item.id)}
|
|
|
|
|
style={STYLES.removeBtn}
|
|
|
|
|
title="Remove from selection"
|
|
|
|
|
aria-label={`Remove ${item.finding_title || 'item'}`}
|
|
|
|
|
disabled={selectedItems.length <= 2}
|
|
|
|
|
>
|
|
|
|
|
<X style={{ width: '12px', height: '12px' }} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Summary field (required, max 255 chars) */}
|
|
|
|
|
<div style={STYLES.section}>
|
|
|
|
|
<label style={STYLES.label} htmlFor="consolidation-summary">
|
|
|
|
|
Summary <span style={{ color: '#EF4444' }}>*</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
id="consolidation-summary"
|
|
|
|
|
type="text"
|
|
|
|
|
value={summary}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setSummary(e.target.value);
|
|
|
|
|
if (summaryError) setSummaryError(null);
|
|
|
|
|
}}
|
|
|
|
|
maxLength={255}
|
|
|
|
|
placeholder="Ticket summary (required)"
|
|
|
|
|
style={{ ...STYLES.input, ...(summaryError ? STYLES.inputError : {}) }}
|
|
|
|
|
/>
|
|
|
|
|
<div style={STYLES.charCount}>{summary.length}/255</div>
|
|
|
|
|
{summaryError && <div style={STYLES.errorMsg}>{summaryError}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Description textarea */}
|
|
|
|
|
<div style={STYLES.section}>
|
|
|
|
|
<label style={STYLES.label} htmlFor="consolidation-description">
|
|
|
|
|
Description
|
|
|
|
|
</label>
|
|
|
|
|
<textarea
|
|
|
|
|
id="consolidation-description"
|
|
|
|
|
value={description}
|
|
|
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
|
|
|
placeholder="Aggregated description of selected findings"
|
|
|
|
|
style={STYLES.textarea}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* CVE ID (optional) */}
|
|
|
|
|
<div style={STYLES.section}>
|
|
|
|
|
<label style={STYLES.label} htmlFor="consolidation-cve">
|
|
|
|
|
CVE ID <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
id="consolidation-cve"
|
|
|
|
|
type="text"
|
|
|
|
|
value={cveId}
|
|
|
|
|
onChange={(e) => setCveId(e.target.value)}
|
|
|
|
|
placeholder="e.g. CVE-2024-12345"
|
|
|
|
|
style={STYLES.input}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Vendor (optional) */}
|
|
|
|
|
<div style={STYLES.section}>
|
|
|
|
|
<label style={STYLES.label} htmlFor="consolidation-vendor">
|
|
|
|
|
Vendor <span style={{ color: '#64748B', fontWeight: 400 }}>(optional)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
id="consolidation-vendor"
|
|
|
|
|
type="text"
|
|
|
|
|
value={vendor}
|
|
|
|
|
onChange={(e) => setVendor(e.target.value)}
|
|
|
|
|
placeholder="e.g. Microsoft"
|
|
|
|
|
style={STYLES.input}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Source Context (read-only) */}
|
|
|
|
|
<div style={STYLES.section}>
|
|
|
|
|
<label style={STYLES.label}>Source Context</label>
|
|
|
|
|
<span style={STYLES.readOnlyBadge}>{sourceContext}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
|
|
|
|
<div style={STYLES.actions}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
style={STYLES.cancelBtn}
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)';
|
|
|
|
|
e.currentTarget.style.color = '#CBD5E1';
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)';
|
|
|
|
|
e.currentTarget.style.color = '#94A3B8';
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSubmit}
|
|
|
|
|
disabled={!canSubmit}
|
|
|
|
|
style={{
|
|
|
|
|
...STYLES.submitBtn,
|
|
|
|
|
...(!canSubmit ? STYLES.submitBtnDisabled : {}),
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
if (canSubmit) {
|
|
|
|
|
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.18)';
|
|
|
|
|
e.currentTarget.style.boxShadow = '0 0 20px rgba(14, 165, 233, 0.15)';
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
|
|
|
|
|
e.currentTarget.style.boxShadow = 'none';
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{submitting ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
|
|
|
|
Creating…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
'Create Ticket'
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|