Files
cve-dashboard/frontend/src/components/pages/MultiVerticalUploadModal.js

429 lines
22 KiB
JavaScript

import React, { useState, useRef } from 'react';
import { X, Upload, FileSpreadsheet, Loader, CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const PURPLE = '#A78BFA';
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const OVERLAY_STYLE = {
position: 'fixed', inset: 0,
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const MODAL_STYLE = {
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
border: `1px solid ${PURPLE}40`,
borderRadius: '1rem',
width: '90%',
maxWidth: '900px',
maxHeight: '85vh',
overflow: 'auto',
padding: '2rem',
boxShadow: `0 0 60px rgba(167, 139, 250, 0.15)`,
};
const DROP_ZONE_STYLE = {
border: `2px dashed ${PURPLE}50`,
borderRadius: '0.75rem',
padding: '3rem 2rem',
textAlign: 'center',
cursor: 'pointer',
transition: 'border-color 0.2s, background 0.2s',
};
const DROP_ZONE_ACTIVE = {
...DROP_ZONE_STYLE,
borderColor: PURPLE,
background: `${PURPLE}10`,
};
const FILE_ROW_STYLE = {
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '0.75rem 1rem',
borderRadius: '0.5rem',
background: 'rgba(15, 23, 42, 0.6)',
border: '1px solid rgba(255,255,255,0.06)',
marginBottom: '0.5rem',
};
// phase: idle → uploading → preview → committing → done | error
export default function MultiVerticalUploadModal({ onClose, onUploadComplete }) {
const [phase, setPhase] = useState('idle');
const [files, setFiles] = useState([]);
const [previewData, setPreviewData] = useState(null);
const [error, setError] = useState(null);
const [dragOver, setDragOver] = useState(false);
const [commitResult, setCommitResult] = useState(null);
const fileInputRef = useRef(null);
// Handle file selection
const handleFiles = (fileList) => {
const newFiles = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.xlsx'));
if (newFiles.length === 0) {
setError('Please select .xlsx files');
return;
}
setFiles(prev => {
const existing = new Set(prev.map(f => f.name));
const unique = newFiles.filter(f => !existing.has(f.name));
return [...prev, ...unique];
});
setError(null);
};
const removeFile = (index) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
// Upload and preview
const handlePreview = async () => {
if (files.length === 0) return;
setPhase('uploading');
setError(null);
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/preview`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Upload failed');
setPhase('idle');
return;
}
setPreviewData(data);
setPhase('preview');
} catch (err) {
setError('Network error: ' + err.message);
setPhase('idle');
}
};
// Commit
const handleCommit = async () => {
if (!previewData || !previewData.files || previewData.files.length === 0) return;
setPhase('committing');
setError(null);
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/commit`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
files: previewData.files.map(f => ({
tempFile: f.tempFile,
vertical: f.vertical,
report_date: f.report_date,
filename: f.filename,
})),
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Commit failed');
setPhase('preview');
return;
}
setCommitResult(data);
setPhase('done');
} catch (err) {
setError('Network error: ' + err.message);
setPhase('preview');
}
};
// Remove a file from preview
const removePreviewFile = (index) => {
setPreviewData(prev => ({
...prev,
files: prev.files.filter((_, i) => i !== index),
}));
};
return (
<div style={OVERLAY_STYLE} onClick={onClose}>
<div style={MODAL_STYLE} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>
Upload Vertical Files
</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem' }}>
<X style={{ width: '20px', height: '20px' }} />
</button>
</div>
{/* Phase: Idle — file selection */}
{phase === 'idle' && (
<>
{/* Drop zone */}
<div
style={dragOver ? DROP_ZONE_ACTIVE : DROP_ZONE_STYLE}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={e => { e.preventDefault(); setDragOver(false); handleFiles(e.dataTransfer.files); }}
onClick={() => fileInputRef.current?.click()}
>
<Upload style={{ width: '32px', height: '32px', color: PURPLE, margin: '0 auto 0.75rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0', marginBottom: '0.25rem' }}>
Drop xlsx files here or click to browse
</div>
<div style={{ fontSize: '0.7rem', color: '#64748B' }}>
Expected format: VERTICAL_YYYY_MM_DD.xlsx (up to 14 files)
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
multiple
style={{ display: 'none' }}
onChange={e => handleFiles(e.target.files)}
/>
{/* Selected files list */}
{files.length > 0 && (
<div style={{ marginTop: '1.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.75rem' }}>
Selected Files ({files.length})
</div>
{files.map((file, i) => (
<div key={i} style={FILE_ROW_STYLE}>
<FileSpreadsheet style={{ width: '16px', height: '16px', color: '#10B981', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: '0.8rem', color: '#E2E8F0' }}>{file.name}</span>
<span style={{ fontSize: '0.65rem', color: '#64748B' }}>{(file.size / 1024).toFixed(0)} KB</span>
<button
onClick={() => removeFile(i)}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.25rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
>
<Trash2 style={{ width: '14px', height: '14px' }} />
</button>
</div>
))}
<button
onClick={handlePreview}
style={{
marginTop: '1rem',
padding: '0.7rem 2rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: 'pointer',
width: '100%',
}}
>
Preview Upload
</button>
</div>
)}
</>
)}
{/* Phase: Uploading */}
{phase === 'uploading' && (
<div style={{ textAlign: 'center', padding: '3rem' }}>
<Loader style={{ width: '32px', height: '32px', color: PURPLE, animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>Parsing {files.length} file(s)...</div>
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.25rem' }}>Extracting verticals and computing diffs</div>
</div>
)}
{/* Phase: Preview */}
{phase === 'preview' && previewData && (
<>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '1rem' }}>
Preview {previewData.files.length} file(s) ready
</div>
{/* Preview table */}
<div style={{ overflowX: 'auto', marginBottom: '1rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr>
<th style={{ padding: '0.5rem', textAlign: 'left', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Vertical</th>
<th style={{ padding: '0.5rem', textAlign: 'left', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Date</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#64748B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Items</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#10B981', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>New</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#F59E0B', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Recurring</th>
<th style={{ padding: '0.5rem', textAlign: 'right', color: '#0EA5E9', borderBottom: '1px solid rgba(255,255,255,0.08)' }}>Resolved</th>
<th style={{ padding: '0.5rem', textAlign: 'center', borderBottom: '1px solid rgba(255,255,255,0.08)' }}></th>
</tr>
</thead>
<tbody>
{previewData.files.map((f, i) => (
<tr key={i}>
<td style={{ padding: '0.5rem', color: PURPLE, fontWeight: '600' }}>{f.vertical}</td>
<td style={{ padding: '0.5rem', color: '#94A3B8' }}>{f.report_date}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#E2E8F0' }}>{f.total_items}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#10B981' }}>{f.diff.new_count}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#F59E0B' }}>{f.diff.recurring_count}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: '#0EA5E9' }}>{f.diff.resolved_count}</td>
<td style={{ padding: '0.5rem', textAlign: 'center' }}>
{previewData.files.length > 1 && (
<button
onClick={() => removePreviewFile(i)}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', padding: '0.2rem' }}
onMouseEnter={e => e.currentTarget.style.color = '#EF4444'}
onMouseLeave={e => e.currentTarget.style.color = '#64748B'}
>
<Trash2 style={{ width: '12px', height: '12px' }} />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<td style={{ padding: '0.5rem', fontWeight: '600', color: '#E2E8F0' }}>Total</td>
<td></td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#E2E8F0' }}>
{previewData.files.reduce((s, f) => s + f.total_items, 0)}
</td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#10B981' }}>
{previewData.files.reduce((s, f) => s + f.diff.new_count, 0)}
</td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#F59E0B' }}>
{previewData.files.reduce((s, f) => s + f.diff.recurring_count, 0)}
</td>
<td style={{ padding: '0.5rem', textAlign: 'right', fontWeight: '600', color: '#0EA5E9' }}>
{previewData.files.reduce((s, f) => s + f.diff.resolved_count, 0)}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{/* Unrecognized files */}
{previewData.unrecognized && previewData.unrecognized.length > 0 && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.5rem' }}>
<div style={{ fontSize: '0.7rem', color: '#EF4444', fontWeight: '600', marginBottom: '0.5rem' }}>
Unrecognized Files ({previewData.unrecognized.length})
</div>
{previewData.unrecognized.map((u, i) => (
<div key={i} style={{ fontSize: '0.7rem', color: '#F87171', marginBottom: '0.25rem' }}>
{u.filename}: {u.error}
</div>
))}
</div>
)}
{/* Actions */}
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button
onClick={() => { setPhase('idle'); setPreviewData(null); }}
style={{
flex: 1, padding: '0.7rem',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.2)',
borderRadius: '0.5rem',
color: '#94A3B8',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
Back
</button>
<button
onClick={handleCommit}
disabled={previewData.files.length === 0}
style={{
flex: 2, padding: '0.7rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: previewData.files.length === 0 ? 'not-allowed' : 'pointer',
opacity: previewData.files.length === 0 ? 0.5 : 1,
}}
>
Commit {previewData.files.length} File(s)
</button>
</div>
</>
)}
{/* Phase: Committing */}
{phase === 'committing' && (
<div style={{ textAlign: 'center', padding: '3rem' }}>
<Loader style={{ width: '32px', height: '32px', color: PURPLE, animation: 'spin 1s linear infinite', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '0.85rem', color: '#E2E8F0' }}>Committing batch...</div>
<div style={{ fontSize: '0.7rem', color: '#64748B', marginTop: '0.25rem' }}>Writing to database with vertical-scoped resolution</div>
</div>
)}
{/* Phase: Done */}
{phase === 'done' && commitResult && (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<CheckCircle style={{ width: '48px', height: '48px', color: '#10B981', margin: '0 auto 1rem' }} />
<div style={{ fontSize: '1rem', color: '#E2E8F0', marginBottom: '0.5rem' }}>Upload Complete</div>
<div style={{ fontSize: '0.75rem', color: '#94A3B8', marginBottom: '1.5rem' }}>
{commitResult.committed.length} vertical(s) committed {commitResult.total_new} new, {commitResult.total_resolved} resolved
</div>
{/* Per-vertical summary */}
<div style={{ textAlign: 'left', marginBottom: '1.5rem' }}>
{commitResult.committed.map((c, i) => (
<div key={i} style={{ ...FILE_ROW_STYLE, justifyContent: 'space-between' }}>
<span style={{ color: PURPLE, fontWeight: '600', fontSize: '0.8rem' }}>{c.vertical}</span>
<span style={{ fontSize: '0.7rem', color: '#64748B' }}>
+{c.new_count} new / {c.recurring_count} recurring / -{c.resolved_count} resolved
</span>
</div>
))}
</div>
<button
onClick={() => { onUploadComplete(); }}
style={{
padding: '0.7rem 2rem',
background: PURPLE,
border: 'none',
borderRadius: '0.5rem',
color: '#FFF',
fontSize: '0.8rem',
fontWeight: '600',
cursor: 'pointer',
}}
>
Done
</button>
</div>
)}
{/* Error display */}
{error && (
<div style={{ marginTop: '1rem', padding: '0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
<span style={{ fontSize: '0.75rem', color: '#F87171' }}>{error}</span>
</div>
)}
</div>
</div>
);
}