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:
@@ -874,6 +874,253 @@ function createVCLMultiVerticalRouter(upload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Data Management — Delete / Rollback
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /vertical/:code
|
||||||
|
* Deletes all data for a single vertical — items, uploads, summary, and snapshots.
|
||||||
|
* Admin only.
|
||||||
|
*
|
||||||
|
* @method DELETE
|
||||||
|
* @route /vertical/:code
|
||||||
|
* @group Admin
|
||||||
|
* @param {string} code — vertical code to delete (e.g., "NTS_AEO")
|
||||||
|
*
|
||||||
|
* @response 200
|
||||||
|
* {
|
||||||
|
* message: string,
|
||||||
|
* deleted: { items: number, uploads: number }
|
||||||
|
* }
|
||||||
|
* @response 400 { error: string } — invalid vertical code
|
||||||
|
* @response 500 { error: string }
|
||||||
|
*/
|
||||||
|
router.delete('/vertical/:code', requireGroup('Admin'), async (req, res) => {
|
||||||
|
const vertical = req.params.code;
|
||||||
|
if (!vertical || vertical.length > 100) return res.status(400).json({ error: 'Invalid vertical code' });
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const { rows: uploadRows } = await client.query(
|
||||||
|
`SELECT id FROM compliance_uploads WHERE vertical = $1`, [vertical]
|
||||||
|
);
|
||||||
|
const uploadIds = uploadRows.map(r => r.id);
|
||||||
|
|
||||||
|
if (uploadIds.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM vcl_multi_vertical_summary WHERE upload_id = ANY($1)`, [uploadIds]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemResult = await client.query(
|
||||||
|
`DELETE FROM compliance_items WHERE vertical = $1`, [vertical]
|
||||||
|
);
|
||||||
|
const uploadResult = await client.query(
|
||||||
|
`DELETE FROM compliance_uploads WHERE vertical = $1`, [vertical]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM compliance_snapshots WHERE vertical = $1`, [vertical]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'vcl_multi_vertical_delete',
|
||||||
|
entityType: 'compliance_vertical',
|
||||||
|
entityId: vertical,
|
||||||
|
details: { items_deleted: itemResult.rowCount, uploads_deleted: uploadResult.rowCount },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Deleted all data for vertical "${vertical}"`,
|
||||||
|
deleted: { items: itemResult.rowCount, uploads: uploadResult.rowCount },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('[VCL Multi] DELETE /vertical/:code error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to delete vertical data' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /all
|
||||||
|
* Deletes ALL multi-vertical data — items, uploads, summary, and snapshots.
|
||||||
|
* Admin only. Nuclear reset.
|
||||||
|
*
|
||||||
|
* @method DELETE
|
||||||
|
* @route /all
|
||||||
|
* @group Admin
|
||||||
|
*
|
||||||
|
* @response 200
|
||||||
|
* {
|
||||||
|
* message: string,
|
||||||
|
* deleted: { items: number, uploads: number }
|
||||||
|
* }
|
||||||
|
* @response 500 { error: string }
|
||||||
|
*/
|
||||||
|
router.delete('/all', requireGroup('Admin'), async (req, res) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
await client.query(`DELETE FROM vcl_multi_vertical_summary`);
|
||||||
|
const itemResult = await client.query(`DELETE FROM compliance_items WHERE vertical IS NOT NULL`);
|
||||||
|
const uploadResult = await client.query(`DELETE FROM compliance_uploads WHERE vertical IS NOT NULL`);
|
||||||
|
await client.query(`DELETE FROM compliance_snapshots WHERE vertical IS NOT NULL AND vertical != ''`);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'vcl_multi_vertical_reset',
|
||||||
|
entityType: 'compliance_vertical',
|
||||||
|
entityId: 'ALL',
|
||||||
|
details: { items_deleted: itemResult.rowCount, uploads_deleted: uploadResult.rowCount },
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'All multi-vertical data has been deleted',
|
||||||
|
deleted: { items: itemResult.rowCount, uploads: uploadResult.rowCount },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('[VCL Multi] DELETE /all error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to reset data' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /upload/:uploadId
|
||||||
|
* Rolls back a specific upload — deletes items introduced by it, reactivates items it resolved.
|
||||||
|
* Admin only. Must be the most recent upload for that vertical.
|
||||||
|
*
|
||||||
|
* @method DELETE
|
||||||
|
* @route /upload/:uploadId
|
||||||
|
* @group Admin
|
||||||
|
* @param {number} uploadId — numeric ID of the upload to roll back
|
||||||
|
*
|
||||||
|
* @response 200
|
||||||
|
* {
|
||||||
|
* message: string,
|
||||||
|
* rolled_back: {
|
||||||
|
* upload_id: number,
|
||||||
|
* vertical: string,
|
||||||
|
* filename: string,
|
||||||
|
* items_deleted: number,
|
||||||
|
* items_reactivated: number
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* @response 400 { error: string } — invalid upload ID, not a multi-vertical upload, or not the most recent upload
|
||||||
|
* @response 404 { error: string } — upload not found
|
||||||
|
* @response 500 { error: string }
|
||||||
|
*/
|
||||||
|
router.delete('/upload/:uploadId', requireGroup('Admin'), async (req, res) => {
|
||||||
|
const uploadId = parseInt(req.params.uploadId, 10);
|
||||||
|
if (isNaN(uploadId)) return res.status(400).json({ error: 'Invalid upload ID' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows: uploadRows } = await pool.query(
|
||||||
|
`SELECT id, filename, report_date, vertical, new_count, resolved_count FROM compliance_uploads WHERE id = $1`,
|
||||||
|
[uploadId]
|
||||||
|
);
|
||||||
|
const upload = uploadRows[0];
|
||||||
|
if (!upload) return res.status(404).json({ error: 'Upload not found' });
|
||||||
|
if (!upload.vertical) return res.status(400).json({ error: 'This upload is not a multi-vertical upload' });
|
||||||
|
|
||||||
|
const { rows: latestRows } = await pool.query(
|
||||||
|
`SELECT id FROM compliance_uploads WHERE vertical = $1 ORDER BY id DESC LIMIT 1`,
|
||||||
|
[upload.vertical]
|
||||||
|
);
|
||||||
|
if (latestRows[0].id !== uploadId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Only the most recent upload for "${upload.vertical}" can be rolled back`,
|
||||||
|
latest_upload_id: latestRows[0].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const deleteNew = await client.query(
|
||||||
|
`DELETE FROM compliance_items WHERE first_seen_upload_id = $1 AND upload_id = $1`, [uploadId]
|
||||||
|
);
|
||||||
|
const reactivate = await client.query(
|
||||||
|
`UPDATE compliance_items SET status = 'active', resolved_upload_id = NULL WHERE resolved_upload_id = $1`, [uploadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rows: prevRows } = await pool.query(
|
||||||
|
`SELECT id FROM compliance_uploads WHERE vertical = $1 AND id < $2 ORDER BY id DESC LIMIT 1`,
|
||||||
|
[upload.vertical, uploadId]
|
||||||
|
);
|
||||||
|
if (prevRows.length > 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE compliance_items SET upload_id = $1, seen_count = GREATEST(seen_count - 1, 1)
|
||||||
|
WHERE upload_id = $2 AND first_seen_upload_id != $2`,
|
||||||
|
[prevRows[0].id, uploadId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(`DELETE FROM vcl_multi_vertical_summary WHERE upload_id = $1`, [uploadId]);
|
||||||
|
await client.query(`DELETE FROM compliance_uploads WHERE id = $1`, [uploadId]);
|
||||||
|
|
||||||
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM compliance_snapshots WHERE vertical = $1 AND snapshot_month = $2`,
|
||||||
|
[upload.vertical, currentMonth]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: req.user.id,
|
||||||
|
username: req.user.username,
|
||||||
|
action: 'vcl_multi_upload_rollback',
|
||||||
|
entityType: 'compliance_upload',
|
||||||
|
entityId: String(uploadId),
|
||||||
|
details: {
|
||||||
|
vertical: upload.vertical,
|
||||||
|
filename: upload.filename,
|
||||||
|
items_deleted: deleteNew.rowCount,
|
||||||
|
items_reactivated: reactivate.rowCount,
|
||||||
|
},
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Rolled back upload "${upload.filename}" for ${upload.vertical}`,
|
||||||
|
rolled_back: {
|
||||||
|
upload_id: uploadId,
|
||||||
|
vertical: upload.vertical,
|
||||||
|
filename: upload.filename,
|
||||||
|
items_deleted: deleteNew.rowCount,
|
||||||
|
items_reactivated: reactivate.rowCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[VCL Multi] DELETE /upload/:uploadId error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to rollback upload: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
|
||||||
import MultiVerticalUploadModal from './MultiVerticalUploadModal';
|
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
|
// Main Page Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -431,6 +591,7 @@ export default function CCPMetricsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
|
||||||
// Drill-down state
|
// Drill-down state
|
||||||
const [selectedVertical, setSelectedVertical] = useState(null);
|
const [selectedVertical, setSelectedVertical] = useState(null);
|
||||||
@@ -499,26 +660,50 @@ export default function CCPMetricsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{(isAdmin() || isEditor()) && (
|
{(isAdmin() || isEditor()) && (
|
||||||
<button
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
onClick={() => setShowUpload(true)}
|
{isAdmin() && (
|
||||||
style={{
|
<button
|
||||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
onClick={() => setShowManage(true)}
|
||||||
padding: '0.6rem 1.2rem',
|
style={{
|
||||||
background: `${PURPLE}20`,
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
border: `1px solid ${PURPLE}60`,
|
padding: '0.6rem 1rem',
|
||||||
borderRadius: '0.5rem',
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
color: PURPLE,
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
fontSize: '0.75rem',
|
borderRadius: '0.5rem',
|
||||||
fontWeight: '600',
|
color: '#EF4444',
|
||||||
cursor: 'pointer',
|
fontSize: '0.75rem',
|
||||||
transition: 'background 0.15s',
|
fontWeight: '600',
|
||||||
}}
|
cursor: 'pointer',
|
||||||
onMouseEnter={e => e.currentTarget.style.background = `${PURPLE}35`}
|
transition: 'background 0.15s',
|
||||||
onMouseLeave={e => e.currentTarget.style.background = `${PURPLE}20`}
|
}}
|
||||||
>
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)'}
|
||||||
<Upload style={{ width: '14px', height: '14px' }} />
|
onMouseLeave={e => e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)'}
|
||||||
Upload Verticals
|
>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -597,6 +782,14 @@ export default function CCPMetricsPage() {
|
|||||||
onUploadComplete={handleUploadComplete}
|
onUploadComplete={handleUploadComplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Data Management Panel */}
|
||||||
|
{showManage && (
|
||||||
|
<DataManagementPanel
|
||||||
|
onClose={() => setShowManage(false)}
|
||||||
|
onDataChanged={fetchData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user