Add VCL compliance reporting: exec report page, device metadata fields, bulk upload
This commit is contained in:
463
frontend/src/components/pages/BulkUploadModal.js
Normal file
463
frontend/src/components/pages/BulkUploadModal.js
Normal file
@@ -0,0 +1,463 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { X, Upload, AlertCircle, Loader, CheckCircle, FileSpreadsheet } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const RED = '#EF4444';
|
||||
const AMBER = '#F59E0B';
|
||||
const EMERALD = '#10B981';
|
||||
|
||||
function mapColumnHeaders(headers) {
|
||||
const mapping = {};
|
||||
for (const h of headers) {
|
||||
const lower = h.toLowerCase().trim();
|
||||
if (lower === 'hostname') mapping.hostname = h;
|
||||
else if (lower === 'resolution date' || lower === 'resolution_date') mapping.resolution_date = h;
|
||||
else if (lower === 'remediation plan' || lower === 'remediation_plan') mapping.remediation_plan = h;
|
||||
else if (lower === 'notes') mapping.notes = h;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function isValidDateString(str) {
|
||||
if (!str || typeof str !== 'string' || str.trim() === '') return false;
|
||||
const d = new Date(str);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
// Check it's a real date by comparing parts
|
||||
const parts = str.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!parts) return false;
|
||||
const [, y, m, day] = parts;
|
||||
return d.getFullYear() === parseInt(y) && (d.getMonth() + 1) === parseInt(m) && d.getDate() === parseInt(day);
|
||||
}
|
||||
|
||||
export default function BulkUploadModal({ onClose }) {
|
||||
const fileRef = useRef(null);
|
||||
const [step, setStep] = useState('upload'); // upload, preview, committing, done
|
||||
const [parsedRows, setParsedRows] = useState([]);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [commitLoading, setCommitLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [commitResult, setCommitResult] = useState(null);
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(sheet, { defval: '' });
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
setError('File contains no data rows');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = Object.keys(jsonData[0]);
|
||||
const colMap = mapColumnHeaders(headers);
|
||||
|
||||
if (!colMap.hostname) {
|
||||
setError('File must contain a Hostname column');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUpdatableFields = colMap.resolution_date || colMap.remediation_plan || colMap.notes;
|
||||
if (!hasUpdatableFields) {
|
||||
setError('No updatable fields found (need Resolution Date, Remediation Plan, or Notes)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build rows for API
|
||||
const rows = jsonData.map(row => {
|
||||
const mapped = { hostname: String(row[colMap.hostname] || '').trim() };
|
||||
if (colMap.resolution_date) {
|
||||
const val = String(row[colMap.resolution_date] || '').trim();
|
||||
mapped.resolution_date = val || null;
|
||||
}
|
||||
if (colMap.remediation_plan) {
|
||||
const val = String(row[colMap.remediation_plan] || '').trim();
|
||||
mapped.remediation_plan = val || null;
|
||||
}
|
||||
if (colMap.notes) {
|
||||
const val = String(row[colMap.notes] || '').trim();
|
||||
mapped.notes = val || null;
|
||||
}
|
||||
return mapped;
|
||||
}).filter(r => r.hostname);
|
||||
|
||||
// Client-side validation
|
||||
const validatedRows = rows.map(row => {
|
||||
const errors = [];
|
||||
if (row.resolution_date && !isValidDateString(row.resolution_date)) {
|
||||
errors.push('Invalid date format for Resolution Date');
|
||||
}
|
||||
if (row.remediation_plan && row.remediation_plan.length > 2000) {
|
||||
errors.push('Remediation Plan exceeds 2000 characters');
|
||||
}
|
||||
return { ...row, _clientErrors: errors };
|
||||
});
|
||||
|
||||
setParsedRows(validatedRows);
|
||||
|
||||
// Call bulk-preview API
|
||||
setPreviewLoading(true);
|
||||
setStep('preview');
|
||||
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl/bulk-preview`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rows: validatedRows.map(({ _clientErrors, ...r }) => r) }),
|
||||
});
|
||||
const previewData = await res.json();
|
||||
if (!res.ok) throw new Error(previewData.error || 'Preview failed');
|
||||
|
||||
setPreview(previewData);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStep('upload');
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!preview || !preview.details) return;
|
||||
|
||||
setCommitLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const changes = preview.details
|
||||
.filter(d => d.status === 'changed')
|
||||
.map(d => {
|
||||
const change = { hostname: d.hostname };
|
||||
if (d.fields.resolution_date) change.resolution_date = d.fields.resolution_date.new;
|
||||
if (d.fields.remediation_plan) change.remediation_plan = d.fields.remediation_plan.new;
|
||||
if (d.fields.notes) change.notes = d.fields.notes.new;
|
||||
return change;
|
||||
});
|
||||
|
||||
const res = await fetch(`${API_BASE}/compliance/vcl/bulk-commit`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ changes }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Commit failed');
|
||||
|
||||
setCommitResult(data);
|
||||
setStep('done');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setCommitLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setParsedRows([]);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div onClick={handleCancel} style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(10,14,39,0.92)',
|
||||
backdropFilter: 'blur(6px)', zIndex: 60,
|
||||
}} />
|
||||
|
||||
{/* Modal */}
|
||||
<div style={{
|
||||
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||
width: '90%', maxWidth: '720px', maxHeight: '85vh',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.99) 0%, rgba(15,23,42,1) 100%)',
|
||||
border: `1px solid ${TEAL}30`,
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
|
||||
zIndex: 61,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<FileSpreadsheet style={{ width: '18px', height: '18px', color: TEAL }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
Bulk Upload
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={handleCancel} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem' }}>
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.75rem 1rem', marginBottom: '1rem',
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F87171', fontSize: '0.8rem', fontFamily: 'monospace',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Upload */}
|
||||
{step === 'upload' && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<div style={{
|
||||
border: '2px dashed rgba(20,184,166,0.3)',
|
||||
borderRadius: '0.75rem',
|
||||
padding: '3rem 2rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = TEAL}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(20,184,166,0.3)'}
|
||||
>
|
||||
<Upload style={{ width: '32px', height: '32px', color: TEAL, margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.85rem', color: '#CBD5E1', marginBottom: '0.5rem' }}>
|
||||
Click to select an .xlsx file
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
Columns: Hostname (required), Resolution Date, Remediation Plan, Notes
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Preview loading */}
|
||||
{step === 'preview' && previewLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Loader style={{ width: '28px', height: '28px', color: TEAL, animation: 'spin 1s linear infinite' }} />
|
||||
<span style={{ marginLeft: '0.75rem', fontFamily: 'monospace', fontSize: '0.8rem', color: '#94A3B8' }}>
|
||||
Analyzing changes…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Preview results */}
|
||||
{step === 'preview' && !previewLoading && preview && (
|
||||
<div>
|
||||
{/* Summary counts */}
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<SummaryBadge label="Matched" value={preview.matched} color={EMERALD} />
|
||||
<SummaryBadge label="Unmatched" value={preview.unmatched} color={AMBER} />
|
||||
<SummaryBadge label="Changes" value={preview.changes} color={TEAL} />
|
||||
<SummaryBadge label="Invalid" value={preview.invalid} color={RED} />
|
||||
</div>
|
||||
|
||||
{/* Changed rows */}
|
||||
{preview.details && preview.details.filter(d => d.status === 'changed').length > 0 && (
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', color: '#475569', marginBottom: '0.5rem' }}>
|
||||
Changed Rows
|
||||
</div>
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '0.375rem' }}>
|
||||
{preview.details.filter(d => d.status === 'changed').map((row, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
background: `${TEAL}05`,
|
||||
}}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#E2E8F0', marginBottom: '0.25rem' }}>
|
||||
{row.hostname}
|
||||
</div>
|
||||
{row.fields && Object.entries(row.fields).map(([field, vals]) => (
|
||||
<div key={field} style={{ display: 'flex', gap: '0.5rem', fontSize: '0.68rem', fontFamily: 'monospace', marginLeft: '0.5rem' }}>
|
||||
<span style={{ color: '#64748B', minWidth: '100px' }}>{field}:</span>
|
||||
<span style={{ color: '#F87171', textDecoration: 'line-through' }}>{vals.old || '(empty)'}</span>
|
||||
<span style={{ color: '#475569' }}>→</span>
|
||||
<span style={{ color: EMERALD }}>{vals.new || '(empty)'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unmatched rows */}
|
||||
{preview.unmatched_rows && preview.unmatched_rows.length > 0 && (
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', color: AMBER, marginBottom: '0.5rem' }}>
|
||||
Unmatched Hostnames ({preview.unmatched_rows.length})
|
||||
</div>
|
||||
<div style={{
|
||||
maxHeight: '100px', overflowY: 'auto',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(245,158,11,0.05)',
|
||||
border: '1px solid rgba(245,158,11,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
fontFamily: 'monospace', fontSize: '0.7rem', color: '#94A3B8',
|
||||
}}>
|
||||
{preview.unmatched_rows.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invalid rows */}
|
||||
{preview.invalid_rows && preview.invalid_rows.length > 0 && (
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em', color: RED, marginBottom: '0.5rem' }}>
|
||||
Invalid Rows ({preview.invalid_rows.length})
|
||||
</div>
|
||||
<div style={{
|
||||
maxHeight: '100px', overflowY: 'auto',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(239,68,68,0.05)',
|
||||
border: '1px solid rgba(239,68,68,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
{preview.invalid_rows.map((row, i) => (
|
||||
<div key={i} style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#F87171', marginBottom: '0.25rem' }}>
|
||||
{row.hostname}: {(row.errors || []).join('; ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Done */}
|
||||
{step === 'done' && commitResult && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<CheckCircle style={{ width: '40px', height: '40px', color: EMERALD, margin: '0 auto 1rem' }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', color: '#E2E8F0', marginBottom: '0.5rem' }}>
|
||||
Changes Committed
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', color: '#94A3B8' }}>
|
||||
{commitResult.committed} device(s) updated
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'flex-end', gap: '0.75rem',
|
||||
padding: '1rem 1.5rem',
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{step === 'preview' && !previewLoading && preview && (
|
||||
<>
|
||||
<button onClick={handleCancel} style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={commitLoading || preview.changes === 0}
|
||||
style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
background: preview.changes > 0 ? `${TEAL}18` : 'transparent',
|
||||
border: `1px solid ${preview.changes > 0 ? TEAL : 'rgba(100,116,139,0.3)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: preview.changes > 0 ? TEAL : '#475569',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: preview.changes > 0 ? 'pointer' : 'default',
|
||||
opacity: commitLoading ? 0.6 : 1,
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{commitLoading && <Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />}
|
||||
Confirm ({preview.changes} changes)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === 'done' && (
|
||||
<button onClick={onClose} style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
background: `${TEAL}18`,
|
||||
border: `1px solid ${TEAL}`,
|
||||
borderRadius: '0.375rem',
|
||||
color: TEAL,
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
{step === 'upload' && (
|
||||
<button onClick={handleCancel} style={{
|
||||
padding: '0.5rem 1.25rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryBadge({ label, value, color }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.5rem 0.875rem',
|
||||
background: `${color}08`,
|
||||
border: `1px solid ${color}30`,
|
||||
borderRadius: '0.375rem',
|
||||
}}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color }}>
|
||||
{value}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2 } from 'lucide-react';
|
||||
import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield, Trash2, Calendar, FileText, Save } from 'lucide-react';
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
@@ -48,6 +48,31 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
const [noteError, setNoteError] = useState(null);
|
||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||
|
||||
// Metadata fields
|
||||
const [resolutionDate, setResolutionDate] = useState('');
|
||||
const [remediationPlan, setRemediationPlan] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaError, setMetaError] = useState(null);
|
||||
|
||||
const handleSaveMetadata = async (fields) => {
|
||||
setMetaSaving(true);
|
||||
setMetaError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}/metadata`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save metadata');
|
||||
} catch (err) {
|
||||
setMetaError(err.message);
|
||||
} finally {
|
||||
setMetaSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -60,6 +85,10 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
// Default selected metrics to first active failing metric
|
||||
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
||||
|
||||
// Populate metadata fields
|
||||
setResolutionDate(data.resolution_date || '');
|
||||
setRemediationPlan(data.remediation_plan || '');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -214,6 +243,80 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Resolution Date */}
|
||||
<Section title="Resolution Date" icon={<Calendar style={{ width: '14px', height: '14px' }} />}>
|
||||
<input
|
||||
type="date"
|
||||
value={resolutionDate}
|
||||
onChange={e => setResolutionDate(e.target.value)}
|
||||
onBlur={() => handleSaveMetadata({ resolution_date: resolutionDate || null })}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F8FAFC',
|
||||
padding: '0.5rem 0.625rem',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Remediation Plan */}
|
||||
<Section title="Remediation Plan" icon={<FileText style={{ width: '14px', height: '14px' }} />}>
|
||||
<textarea
|
||||
value={remediationPlan}
|
||||
onChange={e => {
|
||||
if (e.target.value.length <= 2000) setRemediationPlan(e.target.value);
|
||||
}}
|
||||
placeholder="Describe the remediation plan…"
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%', resize: 'vertical',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#F8FAFC',
|
||||
padding: '0.5rem 0.625rem',
|
||||
fontSize: '0.8rem',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}70`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.4rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: remediationPlan.length > 1900 ? '#F59E0B' : '#475569' }}>
|
||||
{remediationPlan.length}/2000
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSaveMetadata({ remediation_plan: remediationPlan || null })}
|
||||
disabled={metaSaving}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||
padding: '0.3rem 0.6rem',
|
||||
background: `${TEAL}15`,
|
||||
border: `1px solid ${TEAL}60`,
|
||||
borderRadius: '0.25rem',
|
||||
color: TEAL,
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
cursor: metaSaving ? 'wait' : 'pointer',
|
||||
opacity: metaSaving ? 0.6 : 1,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{metaSaving
|
||||
? <Loader style={{ width: '11px', height: '11px', animation: 'spin 1s linear infinite' }} />
|
||||
: <Save style={{ width: '11px', height: '11px' }} />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{metaError && <div style={{ marginTop: '0.4rem', color: '#F87171', fontSize: '0.72rem', fontFamily: 'monospace' }}>{metaError}</div>}
|
||||
</Section>
|
||||
|
||||
{/* Notes */}
|
||||
<Section title="Notes" icon={<MessageSquare style={{ width: '14px', height: '14px' }} />} grow>
|
||||
{detail.notes.length === 0 && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import ComplianceUploadModal from './ComplianceUploadModal';
|
||||
import ComplianceDetailPanel from './ComplianceDetailPanel';
|
||||
import ComplianceChartsPanel from './ComplianceChartsPanel';
|
||||
import MetricInfoPanel from './MetricInfoPanel';
|
||||
import VCLReportPage from './VCLReportPage';
|
||||
import metricDefinitionsRaw from '../../data/metricDefinitions.json';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
@@ -250,6 +251,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
const availableTeams = getAvailableTeams();
|
||||
const [activeTeam, setActiveTeam] = useState(() => availableTeams[0] || 'STEAM');
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
const [vclView, setVclView] = useState(false);
|
||||
const [metricFilter, setMetricFilter] = useState(null);
|
||||
const [hostSearch, setHostSearch] = useState('');
|
||||
const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
|
||||
@@ -408,6 +410,23 @@ export default function CompliancePage({ onNavigate }) {
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
|
||||
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVclView(!vclView)}
|
||||
style={{
|
||||
background: vclView ? `${TEAL}18` : 'transparent',
|
||||
border: `1px solid ${vclView ? TEAL : 'rgba(20,184,166,0.25)'}`,
|
||||
color: vclView ? TEAL : '#475569',
|
||||
padding: '0.5rem 1rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||||
borderRadius: '0.375rem', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!vclView) { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}}
|
||||
onMouseLeave={e => { if (!vclView) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}}
|
||||
>
|
||||
VCL Report
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<button onClick={() => setShowUpload(true)}
|
||||
className="intel-button"
|
||||
@@ -426,8 +445,13 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── VCL Report View ─────────────────────────────────────── */}
|
||||
{vclView && (
|
||||
<VCLReportPage />
|
||||
)}
|
||||
|
||||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||
{availableTeams.length === 0 && !isAdmin() ? (
|
||||
{!vclView && availableTeams.length === 0 && !isAdmin() ? (
|
||||
<div style={{
|
||||
padding: '1.5rem', marginBottom: '1.5rem',
|
||||
borderRadius: '0.5rem', border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
@@ -437,7 +461,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}}>
|
||||
No BU teams assigned to your account. Contact an admin to configure your team access.
|
||||
</div>
|
||||
) : (
|
||||
) : !vclView && (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||
{availableTeams.map(team => {
|
||||
const isActive = activeTeam === team;
|
||||
@@ -463,7 +487,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
)}
|
||||
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{families.length > 0 ? (
|
||||
{!vclView && families.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
Metric Health — click to filter
|
||||
@@ -564,10 +588,10 @@ export default function CompliancePage({ onNavigate }) {
|
||||
) : null}
|
||||
|
||||
{/* ── Historical trend charts ──────────────────────────────── */}
|
||||
<ComplianceChartsPanel />
|
||||
{!vclView && <ComplianceChartsPanel />}
|
||||
|
||||
{/* ── Device table ─────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
{!vclView && <div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)', borderRadius: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
@@ -622,7 +646,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
{/* Column headers */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||
gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
|
||||
padding: '0.5rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.62rem', color: '#334155',
|
||||
@@ -632,6 +656,8 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<span>IP Address</span>
|
||||
<span>Type</span>
|
||||
<span>Failing Metrics</span>
|
||||
<span>Resolution Date</span>
|
||||
<span>Remediation Plan</span>
|
||||
<span>Seen</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@@ -659,7 +685,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── Detail panel ─────────────────────────────────────────── */}
|
||||
{selectedHost && (
|
||||
@@ -805,12 +831,17 @@ export default function CompliancePage({ onNavigate }) {
|
||||
}
|
||||
|
||||
function DeviceRow({ device, selected, onClick }) {
|
||||
const truncateText = (text, maxLen = 80) => {
|
||||
if (!text) return '—';
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '…' : text;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||
gridTemplateColumns: '2fr 1fr 0.8fr 1.8fr 1fr 1.2fr 0.5fr 0.4fr',
|
||||
padding: '0.625rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
@@ -844,6 +875,16 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Resolution Date */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#94A3B8' }}>
|
||||
{device.resolution_date || '—'}
|
||||
</div>
|
||||
|
||||
{/* Remediation Plan */}
|
||||
<div style={{ fontSize: '0.7rem', color: '#94A3B8', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={device.remediation_plan || ''}>
|
||||
{truncateText(device.remediation_plan)}
|
||||
</div>
|
||||
|
||||
{/* Seen count */}
|
||||
<div>
|
||||
<SeenBadge count={device.seen_count} />
|
||||
|
||||
481
frontend/src/components/pages/VCLReportPage.js
Normal file
481
frontend/src/components/pages/VCLReportPage.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Loader, AlertCircle, Upload } from 'lucide-react';
|
||||
import {
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine,
|
||||
PieChart, Pie, Cell, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import BulkUploadModal from './BulkUploadModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const TEAL = '#14B8A6';
|
||||
const EMERALD = '#10B981';
|
||||
const AMBER = '#F59E0B';
|
||||
const RED = '#EF4444';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VCL Stats Bar (Task 11)
|
||||
// ---------------------------------------------------------------------------
|
||||
function VCLStatsBar({ stats }) {
|
||||
if (!stats) return null;
|
||||
|
||||
const cards = [
|
||||
{ label: 'Total Devices', value: stats.total_devices, color: '#CBD5E1' },
|
||||
{ label: 'In-Scope', value: stats.in_scope, color: '#94A3B8' },
|
||||
{ label: 'Compliant', value: stats.compliant, color: EMERALD },
|
||||
{ label: 'Non-Compliant', value: stats.non_compliant, color: RED },
|
||||
{ label: 'Remediations Req.', value: stats.remediations_required, color: AMBER },
|
||||
{ label: 'Current %', value: `${stats.compliance_pct}%`, color: TEAL },
|
||||
{ label: 'Target %', value: `${stats.target_pct}%`, color: AMBER },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{cards.map(card => (
|
||||
<div key={card.label} style={{
|
||||
flex: '1 1 0',
|
||||
minWidth: '120px',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.875rem 1rem',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '1.25rem', fontWeight: '700',
|
||||
color: card.color, marginBottom: '0.25rem',
|
||||
}}>
|
||||
{card.value}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', color: '#475569',
|
||||
}}>
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compliance Overview Trend Chart (Task 12)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ComplianceOverviewChart({ trendData, targetPct }) {
|
||||
if (!trendData || trendData.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
No trend data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AXIS_STYLE = { fill: '#64748B', fontSize: '0.68rem', fontFamily: 'monospace' };
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1.25rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: '#475569', marginBottom: '1rem',
|
||||
}}>
|
||||
Compliance Overview
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={trendData}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tick={AXIS_STYLE} />
|
||||
<YAxis yAxisId="count" tick={AXIS_STYLE} />
|
||||
<YAxis yAxisId="pct" orientation="right" domain={[0, 100]} unit="%" tick={AXIS_STYLE} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'rgba(15,23,42,0.95)',
|
||||
border: '1px solid rgba(20,184,166,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
color: '#CBD5E1',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8' }} />
|
||||
<Bar yAxisId="count" dataKey="compliant_count" fill={EMERALD} fillOpacity={0.7} name="Compliant" />
|
||||
<Line yAxisId="pct" dataKey="compliance_pct" stroke={TEAL} strokeWidth={2} dot={{ r: 3 }} name="Actual %" />
|
||||
{trendData.length >= 2 && (
|
||||
<Line yAxisId="pct" dataKey="forecast_pct" stroke={TEAL} strokeWidth={2} strokeDasharray="5 3" dot={false} name="Forecast %" />
|
||||
)}
|
||||
<ReferenceLine yAxisId="pct" y={targetPct || 95} stroke={AMBER} strokeDasharray="4 4" label={{ value: 'Target', fill: AMBER, fontSize: '0.68rem', fontFamily: 'monospace' }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-Compliant Assets Donut Chart (Task 13)
|
||||
// ---------------------------------------------------------------------------
|
||||
function NonCompliantDonutChart({ donut }) {
|
||||
if (!donut) return null;
|
||||
|
||||
const data = [
|
||||
{ name: 'Blocked', count: donut.blocked?.count || 0, pct: donut.blocked?.pct || 0 },
|
||||
{ name: 'In-Progress', count: donut.in_progress?.count || 0, pct: donut.in_progress?.pct || 0 },
|
||||
].filter(d => d.count > 0);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
No non-compliant assets
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLORS = [RED, AMBER];
|
||||
|
||||
const renderLabel = ({ name, count, pct }) => `${name}: ${count} (${pct}%)`;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1.25rem',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: '#475569', marginBottom: '1rem',
|
||||
}}>
|
||||
Status of Non-Compliant Assets
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
label={renderLabel}
|
||||
labelLine={{ stroke: '#475569' }}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend wrapperStyle={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#94A3B8' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'rgba(15,23,42,0.95)',
|
||||
border: '1px solid rgba(20,184,166,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
color: '#CBD5E1',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heavy Hitters Table (Task 14)
|
||||
// ---------------------------------------------------------------------------
|
||||
function HeavyHittersTable({ heavyHitters }) {
|
||||
if (!heavyHitters || heavyHitters.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
No heavy hitters data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const headerStyle = {
|
||||
padding: '0.625rem 0.75rem',
|
||||
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em', color: TEAL, fontWeight: '600',
|
||||
borderBottom: '1px solid rgba(20,184,166,0.2)',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: '0.625rem 0.75rem',
|
||||
fontSize: '0.75rem', fontFamily: 'monospace', color: '#CBD5E1',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '1.5rem',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '1rem 1.25rem 0.5rem',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: '#475569',
|
||||
}}>
|
||||
Heavy Hitters
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={headerStyle}>Vertical / Team</th>
|
||||
<th style={{ ...headerStyle, textAlign: 'right' }}>Non-Compliant</th>
|
||||
<th style={headerStyle}>Compliance Date</th>
|
||||
<th style={headerStyle}>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{heavyHitters.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={cellStyle}>
|
||||
<div>{row.vertical}</div>
|
||||
{row.team && <div style={{ fontSize: '0.65rem', color: '#64748B' }}>{row.team}</div>}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: 'right', color: RED, fontWeight: '600' }}>
|
||||
{row.non_compliant}
|
||||
</td>
|
||||
<td style={cellStyle}>
|
||||
{row.compliance_date || ''}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{row.notes || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vertical Breakdown Table (Task 15)
|
||||
// ---------------------------------------------------------------------------
|
||||
function VerticalBreakdownTable({ verticalBreakdown }) {
|
||||
if (!verticalBreakdown || verticalBreakdown.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', textAlign: 'center', color: '#475569', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
No vertical breakdown data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collect all actual burndown months and forecast burndown months
|
||||
const allActualMonths = new Set();
|
||||
const allForecastMonths = new Set();
|
||||
verticalBreakdown.forEach(row => {
|
||||
if (row.actual_burndown) Object.keys(row.actual_burndown).forEach(m => allActualMonths.add(m));
|
||||
if (row.forecast_burndown) Object.keys(row.forecast_burndown).forEach(m => allForecastMonths.add(m));
|
||||
});
|
||||
const actualMonths = [...allActualMonths].sort();
|
||||
const forecastMonths = [...allForecastMonths].sort();
|
||||
|
||||
const headerStyle = {
|
||||
padding: '0.5rem 0.5rem',
|
||||
fontSize: '0.58rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em', color: TEAL, fontWeight: '600',
|
||||
borderBottom: '1px solid rgba(20,184,166,0.2)',
|
||||
textAlign: 'left', whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const cellStyle = {
|
||||
padding: '0.5rem 0.5rem',
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', color: '#CBD5E1',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'auto',
|
||||
marginBottom: '1.5rem',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '1rem 1.25rem 0.5rem',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: '#475569',
|
||||
}}>
|
||||
Vertical Breakdown
|
||||
</div>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '900px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={headerStyle}>Vertical</th>
|
||||
<th style={{ ...headerStyle, textAlign: 'right' }}>Compliance %</th>
|
||||
<th style={headerStyle}>Team</th>
|
||||
<th style={{ ...headerStyle, textAlign: 'right' }}>Non-Compliant</th>
|
||||
{actualMonths.map(m => (
|
||||
<th key={`a-${m}`} style={{ ...headerStyle, textAlign: 'right', color: '#94A3B8' }}>
|
||||
{m.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
{forecastMonths.map(m => (
|
||||
<th key={`f-${m}`} style={{ ...headerStyle, textAlign: 'right', color: AMBER }}>
|
||||
{m.slice(5)}*
|
||||
</th>
|
||||
))}
|
||||
<th style={{ ...headerStyle, textAlign: 'right' }}>Blockers</th>
|
||||
<th style={{ ...headerStyle, textAlign: 'right' }}>RAs</th>
|
||||
<th style={headerStyle}>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{verticalBreakdown.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={cellStyle}>{row.vertical}</td>
|
||||
<td style={{ ...cellStyle, textAlign: 'right', color: TEAL }}>
|
||||
{row.compliance_pct}%
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: '#94A3B8' }}>{row.team || ''}</td>
|
||||
<td style={{ ...cellStyle, textAlign: 'right', color: row.non_compliant > 0 ? RED : '#64748B' }}>
|
||||
{row.non_compliant}
|
||||
</td>
|
||||
{actualMonths.map(m => (
|
||||
<td key={`a-${m}`} style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}>
|
||||
{(row.actual_burndown && row.actual_burndown[m]) || 0}
|
||||
</td>
|
||||
))}
|
||||
{forecastMonths.map(m => (
|
||||
<td key={`f-${m}`} style={{ ...cellStyle, textAlign: 'right', color: AMBER }}>
|
||||
{(row.forecast_burndown && row.forecast_burndown[m]) || 0}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cellStyle, textAlign: 'right', color: row.blockers > 0 ? RED : '#64748B' }}>
|
||||
{row.blockers || 0}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, textAlign: 'right', color: '#94A3B8' }}>
|
||||
{row.risk_acceptances || 0}
|
||||
</td>
|
||||
<td style={{ ...cellStyle, color: '#94A3B8', maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{row.notes || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VCL Report Page (Task 10)
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function VCLReportPage() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [trendData, setTrendData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showBulkUpload, setShowBulkUpload] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statsRes, trendRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/compliance/vcl/stats`, { credentials: 'include' }),
|
||||
fetch(`${API_BASE}/compliance/vcl/trend`, { credentials: 'include' }),
|
||||
]);
|
||||
|
||||
if (!statsRes.ok) throw new Error('Failed to load VCL stats');
|
||||
if (!trendRes.ok) throw new Error('Failed to load VCL trend data');
|
||||
|
||||
const statsData = await statsRes.json();
|
||||
const trendDataJson = await trendRes.json();
|
||||
|
||||
setStats(statsData);
|
||||
setTrendData(trendDataJson.months || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '4rem' }}>
|
||||
<Loader style={{ width: '32px', height: '32px', color: TEAL, animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', color: '#F87171', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
<AlertCircle style={{ width: '18px', height: '18px' }} />
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{/* Page sub-header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||
<div style={{
|
||||
fontSize: '0.72rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: '#475569',
|
||||
}}>
|
||||
VCL Executive Report
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBulkUpload(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
padding: '0.5rem 1rem',
|
||||
background: `${TEAL}15`,
|
||||
border: `1px solid ${TEAL}60`,
|
||||
borderRadius: '0.375rem',
|
||||
color: TEAL,
|
||||
fontSize: '0.72rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = `${TEAL}25`; e.currentTarget.style.borderColor = TEAL; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = `${TEAL}15`; e.currentTarget.style.borderColor = `${TEAL}60`; }}
|
||||
>
|
||||
<Upload style={{ width: '14px', height: '14px' }} />
|
||||
Bulk Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<VCLStatsBar stats={stats?.stats} />
|
||||
|
||||
{/* Charts row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.5rem', marginBottom: '1.5rem' }}>
|
||||
<ComplianceOverviewChart trendData={trendData} targetPct={stats?.stats?.target_pct} />
|
||||
<NonCompliantDonutChart donut={stats?.donut} />
|
||||
</div>
|
||||
|
||||
{/* Heavy Hitters */}
|
||||
<HeavyHittersTable heavyHitters={stats?.heavy_hitters} />
|
||||
|
||||
{/* Vertical Breakdown */}
|
||||
<VerticalBreakdownTable verticalBreakdown={stats?.vertical_breakdown} />
|
||||
|
||||
{/* Bulk Upload Modal */}
|
||||
{showBulkUpload && (
|
||||
<BulkUploadModal onClose={() => setShowBulkUpload(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user