Add data management panel with delete vertical, rollback upload, and reset all

Backend:
- DELETE /api/compliance/vcl-multi/vertical/:code — wipe a single vertical
- DELETE /api/compliance/vcl-multi/upload/:uploadId — rollback most recent upload
- DELETE /api/compliance/vcl-multi/all — nuclear reset of all multi-vertical data
- All delete operations are Admin-only and audit-logged

Frontend:
- Manage button (red, Admin-only) in CCP Metrics header
- DataManagementPanel modal showing upload history grouped by vertical
- Per-vertical delete button
- Per-upload rollback button (most recent only)
- Reset All button with confirmation dialog
- Success/error messaging
This commit is contained in:
Jordan Ramos
2026-05-14 11:54:58 -06:00
parent 1eb8eab76f
commit 408aaa7012
2 changed files with 461 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3 } from 'lucide-react';
import { Upload, Building2, ChevronLeft, Loader, AlertCircle, BarChart3, Settings, Trash2, RotateCcw } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
@@ -421,6 +421,166 @@ function MetricDeviceList({ vertical, metricId, onBack }) {
}
// ---------------------------------------------------------------------------
// Data Management Panel
// ---------------------------------------------------------------------------
function DataManagementPanel({ onClose, onDataChanged }) {
const [uploads, setUploads] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null);
const [confirmAction, setConfirmAction] = useState(null); // { type, label, action }
const [message, setMessage] = useState(null);
useEffect(() => {
fetchUploads();
}, []);
const fetchUploads = () => {
setLoading(true);
fetch(`${API_BASE}/compliance/vcl-multi/uploads`, { credentials: 'include' })
.then(r => r.json())
.then(data => { setUploads(data.uploads || []); setLoading(false); })
.catch(() => setLoading(false));
};
const handleDeleteVertical = async (vertical) => {
setActionLoading(vertical);
setMessage(null);
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/vertical/${encodeURIComponent(vertical)}`, {
method: 'DELETE', credentials: 'include',
});
const data = await res.json();
if (!res.ok) { setMessage({ type: 'error', text: data.error }); }
else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); }
} catch (err) { setMessage({ type: 'error', text: err.message }); }
setActionLoading(null);
setConfirmAction(null);
};
const handleRollbackUpload = async (uploadId) => {
setActionLoading(uploadId);
setMessage(null);
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/upload/${uploadId}`, {
method: 'DELETE', credentials: 'include',
});
const data = await res.json();
if (!res.ok) { setMessage({ type: 'error', text: data.error }); }
else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); }
} catch (err) { setMessage({ type: 'error', text: err.message }); }
setActionLoading(null);
setConfirmAction(null);
};
const handleDeleteAll = async () => {
setActionLoading('all');
setMessage(null);
try {
const res = await fetch(`${API_BASE}/compliance/vcl-multi/all`, {
method: 'DELETE', credentials: 'include',
});
const data = await res.json();
if (!res.ok) { setMessage({ type: 'error', text: data.error }); }
else { setMessage({ type: 'success', text: data.message }); fetchUploads(); onDataChanged(); }
} catch (err) { setMessage({ type: 'error', text: err.message }); }
setActionLoading(null);
setConfirmAction(null);
};
// Group uploads by vertical
const verticalGroups = {};
for (const u of uploads) {
if (!verticalGroups[u.vertical]) verticalGroups[u.vertical] = [];
verticalGroups[u.vertical].push(u);
}
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onClose}>
<div style={{ background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '1rem', width: '90%', maxWidth: '800px', maxHeight: '80vh', overflow: 'auto', padding: '2rem' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: '700', color: '#E2E8F0', margin: 0 }}>Manage Data</h2>
{/* ⚠️ CONVENTION: Use lucide-react <X /> icon instead of raw Unicode character */}
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}></button>
</div>
{/* Message */}
{message && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '0.5rem', background: message.type === 'error' ? 'rgba(239,68,68,0.1)' : 'rgba(16,185,129,0.1)', border: `1px solid ${message.type === 'error' ? 'rgba(239,68,68,0.3)' : 'rgba(16,185,129,0.3)'}`, fontSize: '0.75rem', color: message.type === 'error' ? '#F87171' : '#6EE7B7' }}>
{message.text}
</div>
)}
{/* Confirm dialog */}
{confirmAction && (
<div style={{ marginBottom: '1rem', padding: '1rem', borderRadius: '0.5rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.4)' }}>
<div style={{ fontSize: '0.8rem', color: '#F87171', marginBottom: '0.75rem' }}>
{confirmAction.label}
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button onClick={() => setConfirmAction(null)} style={{ padding: '0.4rem 1rem', background: 'transparent', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '0.375rem', color: '#94A3B8', fontSize: '0.75rem', cursor: 'pointer' }}>Cancel</button>
<button onClick={confirmAction.action} style={{ padding: '0.4rem 1rem', background: '#EF4444', border: 'none', borderRadius: '0.375rem', color: '#FFF', fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer' }}>Confirm Delete</button>
</div>
</div>
)}
{/* Delete All button */}
<div style={{ marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<button
onClick={() => setConfirmAction({ label: 'Delete ALL multi-vertical data? This cannot be undone.', action: handleDeleteAll })}
disabled={uploads.length === 0 || actionLoading}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '0.5rem', color: '#EF4444', fontSize: '0.75rem', cursor: uploads.length === 0 ? 'not-allowed' : 'pointer', opacity: uploads.length === 0 ? 0.5 : 1 }}
>
<Trash2 style={{ width: '13px', height: '13px' }} />
Reset All Data
</button>
</div>
{loading && <div style={{ textAlign: 'center', color: '#64748B', fontSize: '0.8rem', padding: '2rem' }}>Loading uploads...</div>}
{!loading && uploads.length === 0 && (
<div style={{ textAlign: 'center', color: '#64748B', fontSize: '0.8rem', padding: '2rem' }}>No uploads yet.</div>
)}
{/* Per-vertical sections */}
{!loading && Object.entries(verticalGroups).map(([vertical, vUploads]) => (
<div key={vertical} style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: '600', color: PURPLE }}>{vertical}</span>
<button
onClick={() => setConfirmAction({ label: `Delete all data for "${vertical}"? This removes all uploads and items for this vertical.`, action: () => handleDeleteVertical(vertical) })}
disabled={actionLoading}
style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', padding: '0.3rem 0.6rem', background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '0.375rem', color: '#EF4444', fontSize: '0.65rem', cursor: 'pointer' }}
>
<Trash2 style={{ width: '11px', height: '11px' }} /> Delete Vertical
</button>
</div>
{vUploads.slice(0, 5).map((u, i) => (
<div key={u.id} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(15,23,42,0.5)', borderRadius: '0.375rem', marginBottom: '0.25rem', fontSize: '0.7rem' }}>
<span style={{ color: '#94A3B8', flex: 1 }}>{u.filename}</span>
<span style={{ color: '#64748B' }}>{u.report_date || '—'}</span>
<span style={{ color: '#64748B', fontSize: '0.6rem' }}>+{u.new_count || 0} / -{u.resolved_count || 0}</span>
{i === 0 && (
<button
onClick={() => setConfirmAction({ label: `Rollback "${u.filename}"? New items will be deleted and resolved items reactivated.`, action: () => handleRollbackUpload(u.id) })}
disabled={actionLoading}
style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.2rem 0.5rem', background: 'none', border: '1px solid rgba(245,158,11,0.4)', borderRadius: '0.25rem', color: '#F59E0B', fontSize: '0.6rem', cursor: 'pointer' }}
>
<RotateCcw style={{ width: '10px', height: '10px' }} /> Rollback
</button>
)}
</div>
))}
{vUploads.length > 5 && (
<div style={{ fontSize: '0.6rem', color: '#64748B', paddingLeft: '0.75rem' }}>...and {vUploads.length - 5} more</div>
)}
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main Page Component
// ---------------------------------------------------------------------------
@@ -431,6 +591,7 @@ export default function CCPMetricsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showUpload, setShowUpload] = useState(false);
const [showManage, setShowManage] = useState(false);
// Drill-down state
const [selectedVertical, setSelectedVertical] = useState(null);
@@ -499,26 +660,50 @@ export default function CCPMetricsPage() {
</p>
</div>
{(isAdmin() || isEditor()) && (
<button
onClick={() => setShowUpload(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1.2rem',
background: `${PURPLE}20`,
border: `1px solid ${PURPLE}60`,
borderRadius: '0.5rem',
color: PURPLE,
fontSize: '0.75rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = `${PURPLE}35`}
onMouseLeave={e => e.currentTarget.style.background = `${PURPLE}20`}
>
<Upload style={{ width: '14px', height: '14px' }} />
Upload Verticals
</button>
<div style={{ display: 'flex', gap: '0.75rem' }}>
{isAdmin() && (
<button
onClick={() => setShowManage(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1rem',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '0.5rem',
color: '#EF4444',
fontSize: '0.75rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'}
>
<Settings style={{ width: '14px', height: '14px' }} />
Manage
</button>
)}
<button
onClick={() => setShowUpload(true)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 1.2rem',
background: `${PURPLE}20`,
border: `1px solid ${PURPLE}60`,
borderRadius: '0.5rem',
color: PURPLE,
fontSize: '0.75rem',
fontWeight: '600',
cursor: 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = `${PURPLE}35`}
onMouseLeave={e => e.currentTarget.style.background = `${PURPLE}20`}
>
<Upload style={{ width: '14px', height: '14px' }} />
Upload Verticals
</button>
</div>
)}
</div>
@@ -597,6 +782,14 @@ export default function CCPMetricsPage() {
onUploadComplete={handleUploadComplete}
/>
)}
{/* Data Management Panel */}
{showManage && (
<DataManagementPanel
onClose={() => setShowManage(false)}
onDataChanged={fetchData}
/>
)}
</div>
);
}