Add re-queue findings from rejected FP submissions

New feature: users can re-queue findings from a rejected FP submission
back into the Ivanti todo queue under a different workflow type (FP,
Archer, CARD, GRANITE, or DECOM). Primary use case is when an FP is
rejected with a recommendation to submit an Archer risk acceptance.

Backend:
- New migration: add requeued_at column to ivanti_fp_submissions
- New endpoint: POST /api/ivanti/fp-workflow/submissions/:id/requeue
  - Validates workflow_type and vendor (required for FP/Archer/DECOM)
  - Creates new pending queue items from original finding data
  - Marks submission as requeued (prevents double re-queue)
  - Audit logs the action

Frontend (ReportingPage.js):
- RequeueConfirmDialog component with workflow type selector and vendor input
- Re-queue Findings button in Edit FP Modal header (rejected submissions only)
- Already re-queued label when submission.requeued_at is set
- Success notification on completion
This commit is contained in:
Jordan Ramos
2026-05-13 16:46:49 -06:00
parent 828e7cc45d
commit 0fefd2a707
4 changed files with 635 additions and 9 deletions

View File

@@ -3649,6 +3649,280 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
);
}
// ---------------------------------------------------------------------------
// RequeueConfirmDialog — confirmation dialog for re-queuing rejected FP findings
// ---------------------------------------------------------------------------
const REQUEUE_WORKFLOW_OPTIONS = [
{ key: 'FP', label: 'FP', col: '#F59E0B', rgb: '245,158,11' },
{ key: 'Archer', label: 'Archer', col: '#0EA5E9', rgb: '14,165,233' },
{ key: 'CARD', label: 'CARD', col: '#10B981', rgb: '16,185,129' },
{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' },
];
function RequeueConfirmDialog({ submission, onClose, onSuccess }) {
const [workflowType, setWorkflowType] = useState('FP');
const [vendor, setVendor] = useState(() => {
// Pre-fill vendor from submission's queue items if available
try {
const items = JSON.parse(submission.queue_item_ids_json || '[]');
if (items.length > 0 && submission.vendor) return submission.vendor;
} catch { /* ignore */ }
return '';
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const needsVendor = workflowType === 'FP' || workflowType === 'Archer';
const canSubmit = !loading && (!needsVendor || vendor.trim().length > 0);
// Count findings
const findingCount = (() => {
try {
const queueIds = JSON.parse(submission.queue_item_ids_json || '[]');
if (queueIds.length > 0) return queueIds.length;
const findingIds = JSON.parse(submission.finding_ids_json || '[]');
return findingIds.length;
} catch { return 0; }
})();
const handleConfirm = async () => {
if (!canSubmit) return;
setLoading(true);
setError('');
try {
const body = { workflow_type: workflowType };
if (needsVendor) body.vendor = vendor.trim();
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/requeue`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Re-queue failed.');
setLoading(false);
return;
}
onSuccess(data);
} catch (err) {
setError(err.message || 'Network error.');
setLoading(false);
}
};
return ReactDOM.createPortal(
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 10020,
background: 'rgba(10, 14, 39, 0.92)',
backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '1rem',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: '100%', maxWidth: '460px',
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))',
border: '2px solid rgba(245, 158, 11, 0.4)',
borderRadius: '0.75rem',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.7), 0 0 28px rgba(245, 158, 11, 0.12)',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Top accent line */}
<div style={{
height: '2px',
background: 'linear-gradient(90deg, transparent, #F59E0B, transparent)',
boxShadow: '0 0 8px rgba(245, 158, 11, 0.4)',
}} />
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '1rem 1.25rem',
borderBottom: '1px solid rgba(245, 158, 11, 0.15)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
<RotateCcw style={{ width: '18px', height: '18px', color: '#F59E0B' }} />
<span style={{
fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700',
color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em',
}}>
Re-queue Findings
</span>
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}
>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* Finding count info */}
<div style={{
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(245, 158, 11, 0.15)',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
}}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#CBD5E1' }}>
<strong style={{ color: '#F59E0B' }}>{findingCount}</strong> finding{findingCount !== 1 ? 's' : ''} will be re-queued from this rejected submission.
</span>
</div>
{/* Workflow type selector */}
<div>
<label style={{
display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
marginBottom: '0.5rem',
}}>
Target Workflow Type
</label>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{REQUEUE_WORKFLOW_OPTIONS.map(({ key, label, col, rgb }) => {
const active = workflowType === key;
return (
<label
key={key}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '0.375rem', padding: '0.45rem 0.5rem', borderRadius: '0.375rem',
background: active ? `rgba(${rgb}, 0.15)` : 'transparent',
border: `1.5px solid ${active ? `rgba(${rgb}, 0.5)` : 'rgba(255,255,255,0.08)'}`,
color: active ? col : '#475569',
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
cursor: 'pointer', transition: 'all 0.15s',
}}
>
<input
type="radio"
name="requeue-workflow-type"
value={key}
checked={active}
onChange={() => setWorkflowType(key)}
style={{ display: 'none' }}
/>
<span style={{
width: '8px', height: '8px', borderRadius: '50%',
background: active ? col : 'rgba(255,255,255,0.1)',
boxShadow: active ? `0 0 6px ${col}` : 'none',
transition: 'all 0.15s',
}} />
{label}
</label>
);
})}
</div>
</div>
{/* Vendor input — conditional */}
{needsVendor && (
<div>
<label style={{
display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.1em',
marginBottom: '0.5rem',
}}>
Vendor <span style={{ color: '#EF4444' }}>*</span>
</label>
<input
type="text"
value={vendor}
onChange={(e) => setVendor(e.target.value)}
placeholder="e.g. Cisco, Juniper, ADTRAN…"
maxLength={200}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(30, 41, 59, 0.6)',
border: '1px solid rgba(245, 158, 11, 0.25)',
borderRadius: '0.375rem', padding: '0.5rem 0.75rem',
fontFamily: 'monospace', fontSize: '0.8rem', color: '#E2E8F0',
outline: 'none', transition: 'border-color 0.2s',
}}
onFocus={(e) => { e.target.style.borderColor = '#F59E0B'; }}
onBlur={(e) => { e.target.style.borderColor = 'rgba(245, 158, 11, 0.25)'; }}
/>
</div>
)}
{/* Error message */}
{error && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: '0.5rem',
padding: '0.625rem 0.75rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.35)',
borderRadius: '0.375rem',
}}>
<AlertCircle style={{ width: '16px', height: '16px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>
{error}
</span>
</div>
)}
</div>
{/* Footer */}
<div style={{
display: 'flex', justifyContent: 'flex-end', gap: '0.625rem',
padding: '0.875rem 1.25rem',
borderTop: '1px solid rgba(245, 158, 11, 0.1)',
}}>
<button
onClick={onClose}
style={{
padding: '0.45rem 1rem', background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)', borderRadius: '0.375rem',
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
transition: 'all 0.15s',
}}
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!canSubmit}
style={{
display: 'flex', alignItems: 'center', gap: '0.375rem',
padding: '0.45rem 1.1rem',
background: canSubmit
? 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.1))'
: 'transparent',
border: `1.5px solid ${canSubmit ? '#F59E0B' : 'rgba(255,255,255,0.06)'}`,
borderRadius: '0.375rem',
color: canSubmit ? '#FBBF24' : '#334155',
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
textTransform: 'uppercase', letterSpacing: '0.05em',
cursor: canSubmit ? 'pointer' : 'not-allowed',
transition: 'all 0.15s',
}}
>
{loading ? (
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
) : (
<RotateCcw style={{ width: '14px', height: '14px' }} />
)}
{loading ? 'Re-queuing…' : 'Confirm Re-queue'}
</button>
</div>
</div>
</div>,
document.body
);
}
// ---------------------------------------------------------------------------
// FpEditModal — edit existing FP submissions (tabbed modal)
// ---------------------------------------------------------------------------
@@ -3666,6 +3940,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
const [libraryDocs, setLibraryDocs] = useState([]);
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
const [statusValue, setStatusValue] = useState('');
const [showRequeueDialog, setShowRequeueDialog] = useState(false);
// Reset form when submission changes
useEffect(() => {
@@ -3806,7 +4081,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
};
const labelStyle = { display: 'block', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#94A3B8', marginBottom: '0.25rem', textTransform: 'uppercase', letterSpacing: '0.06em' };
return ReactDOM.createPortal(
const portal = ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 10010, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }} onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} style={{
width: '640px', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
@@ -3831,6 +4106,28 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
}}>
{statusValue}
</span>
{submission.lifecycle_status === 'rejected' && (
submission.requeued_at ? (
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', fontStyle: 'italic' }}>
Already re-queued
</span>
) : (
<button
onClick={() => setShowRequeueDialog(true)}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
background: 'rgba(245,158,11,0.08)',
border: '1px solid rgba(245,158,11,0.3)',
color: '#F59E0B', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
cursor: 'pointer', lineHeight: 1.2,
}}
>
<RotateCcw style={{ width: '12px', height: '12px' }} />
Re-queue Findings
</button>
)
)}
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '4px', lineHeight: 1 }}>
<X style={{ width: '18px', height: '18px' }} />
@@ -4185,6 +4482,21 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
</div>,
document.body
);
return (
<>
{portal}
{showRequeueDialog && <RequeueConfirmDialog
submission={submission}
onClose={() => setShowRequeueDialog(false)}
onSuccess={(data) => {
setShowRequeueDialog(false);
setResult({ type: 'success', message: `Re-queued ${data.count} finding(s) as ${data.items[0]?.workflow_type || 'new workflow'}` });
if (onSuccess) onSuccess();
}}
/>}
</>
);
}
// ---------------------------------------------------------------------------