import React, { useState, useEffect, useCallback } from 'react'; import { X, AlertCircle, Loader, FileText } from 'lucide-react'; import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor, } 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); // --------------------------------------------------------------------------- // Initialize / regenerate form fields when selectedItems changes // --------------------------------------------------------------------------- useEffect(() => { if (selectedItems.length >= 2) { setSummary(generateConsolidatedSummary(selectedItems)); setDescription(generateConsolidatedDescription(selectedItems)); setCveId(extractFirstCve(selectedItems)); setVendor(extractCommonVendor(selectedItems)); } }, []); // Only on mount — user edits are preserved after item removal // --------------------------------------------------------------------------- // 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 (