feat: add FP attachment library — attach existing CVE documents to FP submissions
- Add GET /api/ivanti/fp-workflow/documents/search endpoint for querying the document library - Update POST /api/ivanti/fp-workflow to accept libraryDocIds for attaching library documents on create - Update POST .../submissions/:id/attachments to accept libraryDocIds on edit - Add AttachmentSourcePicker component with local upload and library search modes - Integrate picker into FpWorkflowModal (create) and FpEditModal (edit) - Track attachment source (local/library) in attachment_results_json for traceability
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare } from 'lucide-react';
|
||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw, Trash2, X, ListTodo, Upload, FileText, Check, AlertTriangle, CornerUpRight, Edit3, Square, CheckSquare, MinusSquare, Search, Database } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import IvantiCountsChart from './IvantiCountsChart';
|
||||
@@ -1808,6 +1808,421 @@ function filterFpItems(items) {
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.doc', '.docx', '.xlsx', '.csv', '.txt', '.zip'];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AttachmentSourcePicker — shared component for local + library attachments
|
||||
// ---------------------------------------------------------------------------
|
||||
function AttachmentSourcePicker({ files, onFilesChange, libraryDocs, onLibraryDocsChange, disabled }) {
|
||||
const [mode, setMode] = useState('local');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState(null);
|
||||
const [fileErrors, setFileErrors] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const dropRef = useRef(null);
|
||||
const debounceRef = useRef(null);
|
||||
|
||||
// Format file size helper
|
||||
const formatSize = (bytes) => {
|
||||
const n = Number(bytes);
|
||||
if (isNaN(n) || n < 0) return '0 B';
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
// File validation
|
||||
const isAllowedExtension = (filename) => {
|
||||
const ext = '.' + filename.split('.').pop().toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
};
|
||||
|
||||
const addFiles = (newFiles) => {
|
||||
if (disabled) return;
|
||||
const errors = [];
|
||||
const valid = [];
|
||||
Array.from(newFiles).forEach(f => {
|
||||
if (!isAllowedExtension(f.name)) {
|
||||
errors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||
} else if (f.size > MAX_FILE_SIZE) {
|
||||
errors.push(`"${f.name}" — exceeds 10 MB limit`);
|
||||
} else {
|
||||
valid.push(f);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
setFileErrors(errors.join('; '));
|
||||
} else {
|
||||
setFileErrors(null);
|
||||
}
|
||||
if (valid.length) onFilesChange([...files, ...valid]);
|
||||
};
|
||||
|
||||
const removeFile = (idx) => {
|
||||
if (disabled) return;
|
||||
onFilesChange(files.filter((_, i) => i !== idx));
|
||||
setFileErrors(null);
|
||||
};
|
||||
|
||||
const removeLibraryDoc = (docId) => {
|
||||
if (disabled) return;
|
||||
onLibraryDocsChange(libraryDocs.filter(d => d.id !== docId));
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||||
|
||||
// Library search with debounce
|
||||
useEffect(() => {
|
||||
if (mode !== 'library') return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const url = searchQuery.trim()
|
||||
? `${API_BASE}/ivanti/fp-workflow/documents/search?q=${encodeURIComponent(searchQuery.trim())}`
|
||||
: `${API_BASE}/ivanti/fp-workflow/documents/search`;
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Search failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setSearchResults(data);
|
||||
} catch (err) {
|
||||
setSearchError(err.message || 'Failed to search documents');
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [searchQuery, mode]);
|
||||
|
||||
const selectLibraryDoc = (doc) => {
|
||||
if (disabled) return;
|
||||
if (libraryDocs.some(d => d.id === doc.id)) return;
|
||||
onLibraryDocsChange([...libraryDocs, {
|
||||
id: doc.id,
|
||||
cve_id: doc.cve_id,
|
||||
vendor: doc.vendor,
|
||||
name: doc.name,
|
||||
file_size: doc.file_size,
|
||||
mime_type: doc.mime_type,
|
||||
}]);
|
||||
};
|
||||
|
||||
const selectedIds = new Set(libraryDocs.map(d => d.id));
|
||||
|
||||
// ---- Styles ----
|
||||
const tabBtnStyle = (active) => ({
|
||||
flex: 1,
|
||||
padding: '0.45rem 0.5rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: active ? '2px solid #0EA5E9' : '2px solid transparent',
|
||||
color: active ? '#0EA5E9' : '#475569',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: '600',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 0.12s',
|
||||
});
|
||||
|
||||
const dropZoneStyle = {
|
||||
border: '1px dashed rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
background: 'rgba(14,165,233,0.03)',
|
||||
transition: 'border-color 0.15s',
|
||||
};
|
||||
|
||||
const searchInputStyle = {
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
background: 'rgba(14,165,233,0.05)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.25rem',
|
||||
padding: '0.45rem 0.6rem 0.45rem 2rem',
|
||||
color: '#CBD5E1',
|
||||
fontSize: '0.78rem',
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const resultItemStyle = (isSelected) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.4rem 0.5rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||
cursor: disabled || isSelected ? 'default' : 'pointer',
|
||||
opacity: isSelected ? 0.5 : 1,
|
||||
background: isSelected ? 'rgba(14,165,233,0.04)' : 'transparent',
|
||||
transition: 'background 0.1s',
|
||||
});
|
||||
|
||||
const badgeStyle = (type) => ({
|
||||
display: 'inline-block',
|
||||
padding: '0.1rem 0.3rem',
|
||||
borderRadius: '0.15rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.58rem',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
...(type === 'local'
|
||||
? { background: 'rgba(14,165,233,0.15)', color: '#0EA5E9', border: '1px solid rgba(14,165,233,0.3)' }
|
||||
: { background: 'rgba(245,158,11,0.15)', color: '#F59E0B', border: '1px solid rgba(245,158,11,0.3)' }
|
||||
),
|
||||
});
|
||||
|
||||
const totalAttachments = files.length + libraryDocs.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Mode toggle tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid rgba(255,255,255,0.06)', marginBottom: '0.625rem' }}>
|
||||
<button
|
||||
style={tabBtnStyle(mode === 'local')}
|
||||
onClick={() => !disabled && setMode('local')}
|
||||
disabled={disabled}
|
||||
>
|
||||
Local Upload
|
||||
</button>
|
||||
<button
|
||||
style={tabBtnStyle(mode === 'library')}
|
||||
onClick={() => !disabled && setMode('library')}
|
||||
disabled={disabled}
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Local Upload mode */}
|
||||
{mode === 'local' && (
|
||||
<div>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
style={dropZoneStyle}
|
||||
>
|
||||
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||
Drop files here or click to browse
|
||||
</div>
|
||||
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
||||
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
|
||||
accept={ALLOWED_EXTENSIONS.join(',')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{fileErrors && (
|
||||
<div style={{ fontSize: '0.68rem', color: '#EF4444', marginTop: '0.3rem' }}>{fileErrors}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Library mode */}
|
||||
{mode === 'library' && (
|
||||
<div>
|
||||
{/* Search input */}
|
||||
<div style={{ position: 'relative', marginBottom: '0.5rem' }}>
|
||||
<Search size={14} style={{ position: 'absolute', left: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#475569' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search documents by name, CVE, or vendor..."
|
||||
disabled={disabled}
|
||||
style={searchInputStyle}
|
||||
/>
|
||||
{searching && (
|
||||
<Loader size={14} style={{ position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
<div style={{
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid rgba(14,165,233,0.1)',
|
||||
borderRadius: '0.25rem',
|
||||
background: 'rgba(15,23,42,0.5)',
|
||||
}}>
|
||||
{searchError && (
|
||||
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#EF4444', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
|
||||
<AlertCircle size={13} />
|
||||
{searchError}
|
||||
</div>
|
||||
)}
|
||||
{!searchError && !searching && searchResults.length === 0 && (
|
||||
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#475569' }}>
|
||||
No documents found
|
||||
</div>
|
||||
)}
|
||||
{!searchError && searching && searchResults.length === 0 && (
|
||||
<div style={{ padding: '0.75rem', textAlign: 'center', fontSize: '0.72rem', color: '#64748B', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.375rem' }}>
|
||||
<Loader size={13} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
{!searchError && searchResults.map(doc => {
|
||||
const isSelected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
style={resultItemStyle(isSelected)}
|
||||
onClick={() => !isSelected && selectLibraryDoc(doc)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<Check size={13} style={{ color: '#10B981', flexShrink: 0 }} />
|
||||
) : (
|
||||
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.72rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||||
{doc.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem', marginTop: '0.1rem' }}>
|
||||
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
|
||||
{doc.vendor && <span>{doc.vendor}</span>}
|
||||
<span>{formatSize(doc.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified attachment list */}
|
||||
{totalAttachments > 0 && (
|
||||
<div style={{ marginTop: '0.625rem' }}>
|
||||
<div style={{
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '0.35rem',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
Attachments ({totalAttachments})
|
||||
</div>
|
||||
{/* Local files */}
|
||||
{files.map((f, i) => (
|
||||
<div key={`local-${i}`} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.3rem 0.25rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||
}}>
|
||||
<span style={badgeStyle('local')}>LOCAL</span>
|
||||
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flex: 1,
|
||||
fontSize: '0.72rem',
|
||||
color: '#CBD5E1',
|
||||
fontFamily: 'monospace',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{f.name}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||
{formatSize(f.size)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: '0.15rem',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Library docs */}
|
||||
{libraryDocs.map(doc => (
|
||||
<div key={`lib-${doc.id}`} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.3rem 0.25rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.03)',
|
||||
}}>
|
||||
<span style={badgeStyle('library')}>LIBRARY</span>
|
||||
<Database size={13} style={{ color: '#F59E0B', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '0.72rem',
|
||||
color: '#CBD5E1',
|
||||
fontFamily: 'monospace',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{doc.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.6rem', color: '#64748B', fontFamily: 'monospace', display: 'flex', gap: '0.5rem' }}>
|
||||
{doc.cve_id && <span style={{ color: '#0EA5E9' }}>{doc.cve_id}</span>}
|
||||
{doc.vendor && <span>{doc.vendor}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '0.62rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>
|
||||
{formatSize(doc.file_size)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeLibraryDoc(doc.id)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#64748B',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: '0.15rem',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
const [name, setName] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
@@ -1815,12 +2230,11 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
const [expirationDate, setExpirationDate] = useState('');
|
||||
const [scopeOverride, setScopeOverride] = useState('Authorized');
|
||||
const [files, setFiles] = useState([]);
|
||||
const [libraryDocs, setLibraryDocs] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState({ step: '', current: 0, total: 0 });
|
||||
const [errors, setErrors] = useState({});
|
||||
const [result, setResult] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const dropRef = useRef(null);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
@@ -1831,6 +2245,7 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
setExpirationDate('');
|
||||
setScopeOverride('Authorized');
|
||||
setFiles([]);
|
||||
setLibraryDocs([]);
|
||||
setSubmitting(false);
|
||||
setProgress({ step: '', current: 0, total: 0 });
|
||||
setErrors({});
|
||||
@@ -1846,44 +2261,6 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, submitting, onClose]);
|
||||
|
||||
const isAllowedExtension = (filename) => {
|
||||
const ext = '.' + filename.split('.').pop().toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
};
|
||||
|
||||
const addFiles = (newFiles) => {
|
||||
const fileErrors = [];
|
||||
const valid = [];
|
||||
Array.from(newFiles).forEach(f => {
|
||||
if (!isAllowedExtension(f.name)) {
|
||||
fileErrors.push(`"${f.name}" — file type not allowed. Accepted: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||
} else if (f.size > MAX_FILE_SIZE) {
|
||||
fileErrors.push(`"${f.name}" — exceeds 10 MB limit`);
|
||||
} else {
|
||||
valid.push(f);
|
||||
}
|
||||
});
|
||||
if (fileErrors.length) {
|
||||
setErrors(prev => ({ ...prev, files: fileErrors.join('; ') }));
|
||||
} else {
|
||||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
||||
}
|
||||
if (valid.length) setFiles(prev => [...prev, ...valid]);
|
||||
};
|
||||
|
||||
const removeFile = (idx) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
setErrors(prev => { const next = { ...prev }; delete next.files; return next; });
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); };
|
||||
|
||||
const validate = () => {
|
||||
const errs = {};
|
||||
if (!name.trim()) errs.name = 'Workflow name is required';
|
||||
@@ -1917,9 +2294,13 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
formData.append('findingIds', JSON.stringify(selectedItems.map(i => i.finding_id)));
|
||||
formData.append('queueItemIds', JSON.stringify(selectedItems.map(i => i.id)));
|
||||
files.forEach(f => formData.append('attachments', f));
|
||||
if (libraryDocs.length > 0) {
|
||||
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: files.length });
|
||||
const totalAttachments = files.length + libraryDocs.length;
|
||||
if (totalAttachments > 0) {
|
||||
setProgress({ step: 'Creating workflow and uploading attachments...', current: 0, total: totalAttachments });
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow`, {
|
||||
@@ -1966,12 +2347,6 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
// ---- Styles ----
|
||||
const overlayStyle = {
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
@@ -2048,6 +2423,19 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
{result.attachmentResults.map((a, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: '600',
|
||||
padding: '0.1rem 0.3rem',
|
||||
borderRadius: '0.2rem',
|
||||
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
|
||||
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
|
||||
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
flexShrink: 0,
|
||||
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
|
||||
<span>{a.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -2073,6 +2461,19 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
{result.attachmentResults.map((a, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem', color: a.success ? '#10B981' : '#EF4444', marginBottom: '0.25rem' }}>
|
||||
{a.success ? <Check size={12} /> : <AlertTriangle size={12} />}
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '0.6rem',
|
||||
fontWeight: '600',
|
||||
padding: '0.1rem 0.3rem',
|
||||
borderRadius: '0.2rem',
|
||||
background: (a.source || 'local') === 'library' ? 'rgba(168,85,247,0.12)' : 'rgba(14,165,233,0.12)',
|
||||
color: (a.source || 'local') === 'library' ? '#A855F7' : '#0EA5E9',
|
||||
border: `1px solid ${(a.source || 'local') === 'library' ? 'rgba(168,85,247,0.3)' : 'rgba(14,165,233,0.3)'}`,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
flexShrink: 0,
|
||||
}}>{(a.source || 'local') === 'library' ? 'Library' : 'Local'}</span>
|
||||
<span>{a.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -2239,59 +2640,16 @@ function FpWorkflowModal({ open, onClose, selectedItems, onSuccess }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
{/* Attachments */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={labelStyle}>Attachments</div>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: '1px dashed rgba(14,165,233,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
background: 'rgba(14,165,233,0.03)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: '#475569', marginBottom: '0.35rem' }} />
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748B' }}>
|
||||
Drop files here or click to browse
|
||||
</div>
|
||||
<div style={{ fontSize: '0.62rem', color: '#475569', marginTop: '0.2rem' }}>
|
||||
Max 10 MB per file · PDF, PNG, JPG, DOC, XLSX, CSV, TXT, ZIP
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ''; }}
|
||||
accept={ALLOWED_EXTENSIONS.join(',')}
|
||||
<AttachmentSourcePicker
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
libraryDocs={libraryDocs}
|
||||
onLibraryDocsChange={setLibraryDocs}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{errors.files && <div style={errorTextStyle}>{errors.files}</div>}
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
{files.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.3rem 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
|
||||
<FileText size={13} style={{ color: '#64748B', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: '0.75rem', color: '#CBD5E1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', flexShrink: 0 }}>{formatSize(f.size)}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
||||
disabled={submitting}
|
||||
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.15rem' }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -2354,6 +2712,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
const [errors, setErrors] = useState({});
|
||||
const [result, setResult] = useState(null);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [libraryDocs, setLibraryDocs] = useState([]);
|
||||
const [additionalFindingIds, setAdditionalFindingIds] = useState(new Set());
|
||||
const [statusValue, setStatusValue] = useState('');
|
||||
|
||||
@@ -2370,6 +2729,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
setErrors({});
|
||||
setResult(null);
|
||||
setFiles([]);
|
||||
setLibraryDocs([]);
|
||||
setAdditionalFindingIds(new Set());
|
||||
}
|
||||
}, [submission]);
|
||||
@@ -2435,10 +2795,13 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
};
|
||||
|
||||
const handleUploadAttachments = async () => {
|
||||
if (files.length === 0) return;
|
||||
if (files.length === 0 && libraryDocs.length === 0) return;
|
||||
setSaving(true); setResult(null);
|
||||
const formData = new FormData();
|
||||
files.forEach(f => formData.append('attachments', f));
|
||||
if (libraryDocs.length > 0) {
|
||||
formData.append('libraryDocIds', JSON.stringify(libraryDocs.map(d => d.id)));
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/ivanti/fp-workflow/submissions/${submission.id}/attachments`, {
|
||||
method: 'POST', credentials: 'include', body: formData,
|
||||
@@ -2448,6 +2811,7 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
const successCount = (data.attachmentResults || []).filter(r => r.success).length;
|
||||
setResult({ type: 'success', message: `Uploaded ${successCount} file(s).` });
|
||||
setFiles([]);
|
||||
setLibraryDocs([]);
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
setResult({ type: 'error', message: data.error || 'Failed to upload attachments.' });
|
||||
@@ -2731,13 +3095,39 @@ function FpEditModal({ open, onClose, submission, queueItems, onSuccess }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '0.625rem 0.75rem', borderRadius: '0.375rem',
|
||||
background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.15)',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', color: '#F59E0B',
|
||||
}}>
|
||||
To add additional attachments, upload them directly in the Ivanti platform on the workflow detail page.
|
||||
</div>
|
||||
{!isApproved && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<AttachmentSourcePicker
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
libraryDocs={libraryDocs}
|
||||
onLibraryDocsChange={setLibraryDocs}
|
||||
disabled={isApproved}
|
||||
/>
|
||||
{(files.length > 0 || libraryDocs.length > 0) && (
|
||||
<button
|
||||
onClick={handleUploadAttachments}
|
||||
disabled={saving}
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.4rem 1rem',
|
||||
background: saving ? 'rgba(245,158,11,0.08)' : 'rgba(245,158,11,0.15)',
|
||||
border: `1px solid ${saving ? 'rgba(245,158,11,0.15)' : 'rgba(245,158,11,0.4)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: saving ? '#92700C' : '#F59E0B',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '700',
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{saving ? 'Uploading…' : `Upload ${files.length + libraryDocs.length} Attachment(s)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user