Add CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped
resolution logic, executive-level aggregated reporting, and drill-down
by vertical and metric. Supports daily upload cadence and batch commit.
Backend:
- Migration: add vertical column to compliance_items/uploads, create
vcl_multi_vertical_summary table
- New route module: routes/vclMultiVertical.js with preview, commit,
stats, trend, metric drill-down, device list, and burndown endpoints
- New helpers: parseVerticalFilename(), computeVerticalBurndown()
- Vertical-scoped resolution: uploading one vertical never resolves
items from other verticals
Frontend:
- CCPMetricsPage with stats bar, trend chart, donut, vertical table
- Drill-down: vertical -> metrics by category -> device list
- Per-vertical burndown forecast chart
- MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit
- Nav entry: CCP Metrics (Building2 icon)
Docs:
- Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
2026-05-14 09:49:59 -06:00
|
|
|
import React, { useState, useRef } from 'react';
|
2026-05-14 10:00:00 -06:00
|
|
|
import { X, Upload, FileSpreadsheet, Loader, CheckCircle, AlertCircle, Trash2 } from 'lucide-react';
|
Add CCP Metrics page with multi-vertical VCL upload and cross-org reporting
New feature: multi-file per-vertical compliance xlsx upload with scoped
resolution logic, executive-level aggregated reporting, and drill-down
by vertical and metric. Supports daily upload cadence and batch commit.
Backend:
- Migration: add vertical column to compliance_items/uploads, create
vcl_multi_vertical_summary table
- New route module: routes/vclMultiVertical.js with preview, commit,
stats, trend, metric drill-down, device list, and burndown endpoints
- New helpers: parseVerticalFilename(), computeVerticalBurndown()
- Vertical-scoped resolution: uploading one vertical never resolves
items from other verticals
Frontend:
- CCPMetricsPage with stats bar, trend chart, donut, vertical table
- Drill-down: vertical -> metrics by category -> device list
- Per-vertical burndown forecast chart
- MultiVerticalUploadModal: multi-file drag-drop, batch preview, commit
- Nav entry: CCP Metrics (Building2 icon)
Docs:
- Design brief for stakeholder meeting (docs/vcl-multi-vertical-design-brief.md)
2026-05-14 09:49:59 -06:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|