Add Remediate workflow type to Ivanti Queue with remediation notes
- Add 'Remediate' as a valid workflow type (vendor-required, like FP/Archer) - Create queue_remediation_notes table with FK cascade and 5000 char limit - Add POST/GET /api/ivanti/todo-queue/:id/notes endpoints - Include remediation_notes_count in queue item GET response - Add RemediationModal component for viewing/adding notes - Add notes count badge on Remediate queue items (purple #A855F7 theme) - Add delete confirmation warning when removing items with notes - Append remediation notes to Jira ticket descriptions - Add property-based tests for all correctness properties
This commit is contained in:
362
frontend/src/components/RemediationModal.js
Normal file
362
frontend/src/components/RemediationModal.js
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Loader, AlertCircle, Send, RefreshCw } from 'lucide-react';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles — matches dark theme tactical intelligence aesthetic
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLES = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backdrop: {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
},
|
||||
content: {
|
||||
position: 'relative',
|
||||
background: 'linear-gradient(135deg, #1E293B, #0F172A)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '16px',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '560px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
zIndex: 101,
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
},
|
||||
title: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 700,
|
||||
color: '#A855F7',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#94A3B8',
|
||||
marginTop: '0.35rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '400px',
|
||||
},
|
||||
closeBtn: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: 'pointer',
|
||||
padding: '0.25rem',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
textarea: {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '100px',
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '0.75rem',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '0.85rem',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
},
|
||||
charCounter: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: '#64748B',
|
||||
textAlign: 'right',
|
||||
marginTop: '0.25rem',
|
||||
},
|
||||
submitBtn: {
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(168, 85, 247, 0.4)',
|
||||
background: 'rgba(168, 85, 247, 0.15)',
|
||||
color: '#C084FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.4,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
noteItem: {
|
||||
padding: '0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
background: 'rgba(14, 165, 233, 0.04)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
noteMeta: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.6rem',
|
||||
color: '#64748B',
|
||||
marginBottom: '0.35rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
noteText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: '#CBD5E1',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
error: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: '0.5rem',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
errorText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#FCA5A5',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
padding: '1.5rem 0',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: '#475569',
|
||||
},
|
||||
divider: {
|
||||
height: '1px',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
margin: '1rem 0',
|
||||
},
|
||||
retryBtn: {
|
||||
padding: '0.4rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
color: '#7DD3FC',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
marginTop: '0.5rem',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RemediationModal — add and view remediation notes for a queue item
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function RemediationModal({ item, onClose, onNoteAdded }) {
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [newNoteText, setNewNoteText] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [fetchError, setFetchError] = useState(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch existing notes on mount
|
||||
// ---------------------------------------------------------------------------
|
||||
const fetchNotes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setNotes(data);
|
||||
} catch (e) {
|
||||
setFetchError(e.message || 'Failed to load notes.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [item.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotes();
|
||||
}, [fetchNotes]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit new note
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!newNoteText.trim() || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/todo-queue/${item.id}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ note_text: newNoteText }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
// Prepend new note to list (most recent first)
|
||||
setNotes((prev) => [data, ...prev]);
|
||||
setNewNoteText('');
|
||||
if (onNoteAdded) onNoteAdded();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to save note.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [newNoteText, saving, item.id, onNoteAdded]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format date as YYYY-MM-DD
|
||||
// ---------------------------------------------------------------------------
|
||||
const formatDate = (dateStr) => {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = newNoteText.trim().length > 0 && !saving;
|
||||
const remaining = 5000 - newNoteText.length;
|
||||
|
||||
return (
|
||||
<div style={STYLES.overlay}>
|
||||
<div style={STYLES.backdrop} onClick={onClose} />
|
||||
<div style={STYLES.content}>
|
||||
{/* Header */}
|
||||
<div style={STYLES.header}>
|
||||
<div>
|
||||
<div style={STYLES.title}>Remediation Notes</div>
|
||||
<div style={STYLES.subtitle} title={item.finding_title || item.finding_id}>
|
||||
{item.finding_title || item.finding_id}
|
||||
</div>
|
||||
<div style={{ ...STYLES.subtitle, fontSize: '0.6rem', color: '#64748B' }}>
|
||||
ID: {item.finding_id}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={STYLES.closeBtn} aria-label="Close modal">
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New note input */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<textarea
|
||||
value={newNoteText}
|
||||
onChange={(e) => setNewNoteText(e.target.value)}
|
||||
maxLength={5000}
|
||||
placeholder="Describe what remediation steps were taken…"
|
||||
style={STYLES.textarea}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div style={STYLES.charCounter}>
|
||||
{remaining} characters remaining
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div style={STYLES.error}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={STYLES.errorText}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
style={{
|
||||
...STYLES.submitBtn,
|
||||
...(canSubmit ? {} : STYLES.submitBtnDisabled),
|
||||
}}
|
||||
>
|
||||
<Send style={{ width: '14px', height: '14px' }} />
|
||||
{saving ? 'Saving...' : 'Add Note'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={STYLES.divider} />
|
||||
|
||||
{/* Notes list */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||
<Loader style={{ width: '20px', height: '20px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto' }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569', marginTop: '0.5rem' }}>
|
||||
Loading notes...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fetchError && !loading && (
|
||||
<div style={STYLES.error}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={STYLES.errorText}>{fetchError}</span>
|
||||
<button onClick={fetchNotes} style={STYLES.retryBtn}>
|
||||
<RefreshCw style={{ width: '12px', height: '12px' }} />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && notes.length === 0 && (
|
||||
<div style={STYLES.emptyState}>
|
||||
No remediation notes yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && notes.length > 0 && (
|
||||
<div>
|
||||
{notes.map((note) => (
|
||||
<div key={note.id} style={STYLES.noteItem}>
|
||||
<div style={STYLES.noteMeta}>
|
||||
<span style={{ color: '#A855F7', fontWeight: 600 }}>{note.username}</span>
|
||||
<span>{formatDate(note.created_at)}</span>
|
||||
</div>
|
||||
<div style={STYLES.noteText}>{note.note_text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText } from 'lucide-react';
|
||||
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet, FileText, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ConsolidationModal from '../ConsolidationModal';
|
||||
import LoaderModal from '../LoaderModal';
|
||||
import TemplateSelector from '../TemplateSelector';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||
import RemediationModal from '../RemediationModal';
|
||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor, appendRemediationNotes } from '../../utils/jiraConsolidation';
|
||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
||||
|
||||
@@ -315,6 +316,15 @@ export default function IvantiTodoQueuePage() {
|
||||
// Archer Template Selector panel — tracks which item ID has the panel expanded (Requirement 5.1)
|
||||
const [templatePanelOpenId, setTemplatePanelOpenId] = useState(null);
|
||||
|
||||
// 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);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -479,7 +489,7 @@ export default function IvantiTodoQueuePage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating action bar — "Create Jira Ticket" handler (Requirements 2.3, 2.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleCreateJiraTicket = useCallback(() => {
|
||||
const handleCreateJiraTicket = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
if (selectedIds.size === 1) {
|
||||
@@ -488,11 +498,27 @@ export default function IvantiTodoQueuePage() {
|
||||
if (!item) return;
|
||||
setSingleJiraItem(item);
|
||||
const items = [item];
|
||||
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 */ }
|
||||
}
|
||||
|
||||
setSingleJiraForm({
|
||||
cve_id: extractFirstCve(items),
|
||||
vendor: extractCommonVendor(items),
|
||||
summary: generateConsolidatedSummary(items),
|
||||
description: generateConsolidatedDescription(items),
|
||||
description,
|
||||
source_context: 'ivanti_queue',
|
||||
project_key: '',
|
||||
issue_type: '',
|
||||
@@ -579,17 +605,51 @@ export default function IvantiTodoQueuePage() {
|
||||
setSelectionMode(false);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow type color helper
|
||||
// ---------------------------------------------------------------------------
|
||||
const getWorkflowColor = (workflowType) => {
|
||||
switch (workflowType) {
|
||||
case 'FP': return { col: '#F59E0B', rgb: '245,158,11' };
|
||||
case 'Archer': return { col: '#0EA5E9', rgb: '14,165,233' };
|
||||
case 'CARD': return { col: '#10B981', rgb: '16,185,129' };
|
||||
case 'GRANITE': return { col: '#A1887F', rgb: '161,136,127' };
|
||||
case 'DECOM': return { col: '#EF4444', rgb: '239,68,68' };
|
||||
default: return { col: '#94A3B8', rgb: '148,163,184' };
|
||||
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' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -739,7 +799,11 @@ export default function IvantiTodoQueuePage() {
|
||||
? cves.slice(0, 2).join(', ') + (cves.length > 2 ? ` +${cves.length - 2}` : '')
|
||||
: '';
|
||||
const isArcherItem = item.workflow_type === 'Archer';
|
||||
const isRemediateItem = item.workflow_type === 'Remediate';
|
||||
const isTemplatePanelOpen = templatePanelOpenId === item.id;
|
||||
const noteCount = localNoteCounts[item.id] !== undefined
|
||||
? localNoteCounts[item.id]
|
||||
: (item.remediation_notes_count || 0);
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
@@ -823,6 +887,75 @@ export default function IvantiTodoQueuePage() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ticket link badge (Requirements 6.3, 6.4) */}
|
||||
{ticketLinks[item.id] && (
|
||||
<a
|
||||
@@ -1099,6 +1232,69 @@ export default function IvantiTodoQueuePage() {
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
</span>
|
||||
</div>
|
||||
<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}</span> remediation note{deleteConfirmItem._noteCount !== 1 ? 's' : ''}.
|
||||
Deleting this item will <span style={{ color: '#EF4444', fontWeight: 600 }}>permanently delete</span> all associated remediation notes.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
style={STYLES.btnCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1468,6 +1468,7 @@ function AddToQueuePopover({ finding, anchorRect, queueForm, setQueueForm, onAdd
|
||||
{ key: 'CARD', col: '#10B981', rgb: '16,185,129' },
|
||||
{ key: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
|
||||
{ key: 'DECOM', col: '#EF4444', rgb: '239,68,68' },
|
||||
{ key: 'Remediate', col: '#A855F7', rgb: '168,85,247' },
|
||||
].map(({ key, col, rgb }) => {
|
||||
const active = queueForm.workflowType === key;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user