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:
@@ -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();
|
||||
}}
|
||||
/>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user