diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js
index 8f02d0c..7299ef3 100644
--- a/backend/routes/compliance.js
+++ b/backend/routes/compliance.js
@@ -200,9 +200,10 @@ function groupByHostname(rows, noteHostnames) {
});
// Use the highest seen_count and earliest first_seen across all metrics
if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count;
- if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) {
+ if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen))
dev.first_seen = row.first_seen;
- }
+ if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen))
+ dev.last_seen = row.last_seen;
}
return Object.values(deviceMap);
@@ -419,7 +420,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
ru.report_date AS resolved_on
FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
- LEFT JOIN compliance_uploads lu ON ci.upload_id = fu.id
+ LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.team = ? AND ci.status = ?
ORDER BY ci.hostname, ci.metric_id`,
@@ -466,7 +467,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) {
ru.report_date AS resolved_on
FROM compliance_items ci
LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
- LEFT JOIN compliance_uploads lu ON ci.upload_id = fu.id
+ LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id
LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
WHERE ci.hostname = ?
ORDER BY ci.status DESC, ci.metric_id`,
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 4d8f369..506691d 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -13,6 +13,7 @@ import CalendarWidget from './components/CalendarWidget';
import ReportingPage from './components/pages/ReportingPage';
import KnowledgeBasePage from './components/pages/KnowledgeBasePage';
import ExportsPage from './components/pages/ExportsPage';
+import CompliancePage from './components/pages/CompliancePage';
import './App.css';
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
@@ -1043,6 +1044,7 @@ export default function App() {
{/* Page content */}
{currentPage === 'reporting' && }
+ {currentPage === 'compliance' && }
{currentPage === 'knowledge-base' && }
{currentPage === 'exports' && }
diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js
index a22c0f9..365976c 100644
--- a/frontend/src/components/NavDrawer.js
+++ b/frontend/src/components/NavDrawer.js
@@ -1,11 +1,12 @@
import React from 'react';
-import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react';
+import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react';
const NAV_ITEMS = [
- { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
- { id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' },
- { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
- { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
+ { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
+ { id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' },
+ { id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
+ { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
+ { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
];
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js
new file mode 100644
index 0000000..6d582cd
--- /dev/null
+++ b/frontend/src/components/pages/ComplianceDetailPanel.js
@@ -0,0 +1,334 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+const TEAL = '#14B8A6';
+
+const CATEGORY_COLORS = {
+ 'Vulnerability Management': '#EF4444',
+ 'Access & MFA': '#F59E0B',
+ 'Logging & Monitoring': '#8B5CF6',
+ 'End-of-Life OS': '#F97316',
+ 'Decommissioned Assets': '#64748B',
+ 'Asset Data Quality': '#64748B',
+ 'Application Security': '#0EA5E9',
+ 'Disaster Recovery': TEAL,
+ 'Endpoint Protection': '#F97316',
+};
+
+function categoryColor(category) {
+ return CATEGORY_COLORS[category] || '#94A3B8';
+}
+
+function MetricChip({ metricId, category, status }) {
+ const color = status === 'resolved' ? '#64748B' : categoryColor(category);
+ return (
+
+ {metricId}
+
+ );
+}
+
+export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded }) {
+ const [detail, setDetail] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [noteText, setNoteText] = useState('');
+ const [noteMetric, setNoteMetric] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [noteError, setNoteError] = useState(null);
+
+ const fetchDetail = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Failed to load device');
+ setDetail(data);
+
+ // Default note metric to first active failing metric
+ const firstActive = (data.metrics || []).find(m => m.status === 'active');
+ if (firstActive) setNoteMetric(firstActive.metric_id);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [hostname]);
+
+ useEffect(() => { fetchDetail(); }, [fetchDetail]);
+
+ const handleAddNote = async () => {
+ if (!noteText.trim() || !noteMetric) return;
+ setSubmitting(true);
+ setNoteError(null);
+ try {
+ const res = await fetch(`${API_BASE}/compliance/notes`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Failed to save note');
+ setNoteText('');
+ await fetchDetail();
+ if (onNoteAdded) onNoteAdded();
+ } catch (err) {
+ setNoteError(err.message);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || [];
+ const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || [];
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+
+
+
+ {hostname}
+
+ {detail && (
+
+ {detail.ip_address && (
+ {detail.ip_address}
+ )}
+ {detail.device_type && (
+ · {detail.device_type}
+ )}
+ · {detail.team}
+
+ )}
+
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {error && (
+
+ )}
+
+ {!loading && !error && detail && (
+
+
+ {/* Active failing metrics */}
+ {activeMetrics.length > 0 && (
+
}>
+ {activeMetrics.map(m => (
+
+ ))}
+
+ )}
+
+ {/* Resolved metrics */}
+ {resolvedMetrics.length > 0 && (
+
+ {resolvedMetrics.map(m => (
+
+ ))}
+
+ )}
+
+ {/* Upload history summary */}
+ {activeMetrics.length > 0 && (
+
}>
+ {activeMetrics.map(m => (
+
+
+
+ 2 ? '#F59E0B' : '#94A3B8' }}>
+ {m.seen_count}× seen
+
+ {m.first_seen && since {m.first_seen}}
+
+
+ ))}
+
+ )}
+
+ {/* Notes */}
+
} grow>
+ {detail.notes.length === 0 && (
+
No notes yet
+ )}
+ {detail.notes.map(n => (
+
+
+ m.metric_id === n.metric_id)?.category || ''} />
+
+ {n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
+
+
+
{n.note}
+
+ ))}
+
+ {/* Add note */}
+
+ {activeMetrics.length > 1 && (
+
+ )}
+
+
+ {noteError &&
{noteError}
}
+
+
+
+ )}
+
+ >
+ );
+}
+
+function Section({ title, icon, children, muted, grow }) {
+ return (
+
+
+ {icon && {icon}}
+ {title}
+
+ {children}
+
+ );
+}
+
+function MetricRow({ metric, resolved }) {
+ const color = resolved ? '#475569' : categoryColor(metric.category);
+ const extra = metric.extra || {};
+
+ // Surface the most useful extra fields per metric type
+ const highlights = [];
+ if (extra['CVEs_Associated']) highlights.push({ label: 'CVEs', value: extra['CVEs_Associated'] });
+ if (extra['SLA_Status']) highlights.push({ label: 'SLA', value: extra['SLA_Status'] });
+ if (extra['Due_Date']) highlights.push({ label: 'Due', value: extra['Due_Date'] });
+ if (extra['Normalized - Operating System'])
+ highlights.push({ label: 'OS', value: `${extra['Normalized - Operating System']} ${extra['Normalized - Operating System Version'] || ''}`.trim() });
+ if (extra['EOS - End of Service Life'])
+ highlights.push({ label: 'EoL', value: extra['EOS - End of Service Life'] });
+ if (extra['Splunk - Last Seen']) highlights.push({ label: 'Splunk', value: extra['Splunk - Last Seen'] });
+ if (extra['MFA - Software']) highlights.push({ label: 'MFA SW', value: extra['MFA - Software'] });
+
+ return (
+
+
+
+ {resolved && resolved {metric.resolved_on || ''}}
+
+ {metric.metric_desc && (
+
+ {metric.metric_desc.length > 100 ? metric.metric_desc.slice(0, 100) + '…' : metric.metric_desc}
+
+ )}
+ {highlights.map(h => (
+
+ {h.label}
+
+ {String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/pages/CompliancePage.js b/frontend/src/components/pages/CompliancePage.js
new file mode 100644
index 0000000..50b0c76
--- /dev/null
+++ b/frontend/src/components/pages/CompliancePage.js
@@ -0,0 +1,502 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Upload, MessageSquare, RefreshCw, AlertCircle, Loader } from 'lucide-react';
+import { useAuth } from '../../contexts/AuthContext';
+import ComplianceUploadModal from './ComplianceUploadModal';
+import ComplianceDetailPanel from './ComplianceDetailPanel';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+const TEAL = '#14B8A6';
+const TEAMS = ['STEAM', 'ACCESS-ENG'];
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+const STATUS_COLOR = {
+ 'Meets/Exceeds Target': '#10B981',
+ 'Within 15% of Target': '#F59E0B',
+ 'Below 15% of Target': '#EF4444',
+};
+
+const CATEGORY_COLORS = {
+ 'Vulnerability Management': '#EF4444',
+ 'Access & MFA': '#F59E0B',
+ 'Logging & Monitoring': '#8B5CF6',
+ 'End-of-Life OS': '#F97316',
+ 'Decommissioned Assets': '#64748B',
+ 'Asset Data Quality': '#64748B',
+ 'Application Security': '#0EA5E9',
+ 'Disaster Recovery': TEAL,
+ 'Endpoint Protection': '#F97316',
+};
+
+function metricColor(metricId, category) {
+ return CATEGORY_COLORS[category] || '#94A3B8';
+}
+
+function statusColor(status) {
+ return STATUS_COLOR[status] || '#EF4444';
+}
+
+function pctDisplay(pct) {
+ return `${Math.round(pct * 100)}%`;
+}
+
+// Deduplicate summary entries — one per metric_id for the selected team
+// (exclude aggregate "ALL: NTS-AEO" rows)
+function teamMetrics(entries, team) {
+ return entries.filter(e => e.team === team);
+}
+
+// ---------------------------------------------------------------------------
+// Sub-components
+// ---------------------------------------------------------------------------
+function MetricHealthCard({ entry, active, onClick }) {
+ const color = statusColor(entry.status);
+ const isOk = entry.status === 'Meets/Exceeds Target';
+
+ return (
+
+ );
+}
+
+function MetricBadge({ metricId, category }) {
+ const color = CATEGORY_COLORS[category] || '#94A3B8';
+ return (
+
+ {metricId}
+
+ );
+}
+
+function SeenBadge({ count }) {
+ const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
+ return (
+
+ {count}×
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main Page
+// ---------------------------------------------------------------------------
+export default function CompliancePage() {
+ const { canWrite } = useAuth();
+
+ const [activeTeam, setActiveTeam] = useState('STEAM');
+ const [activeTab, setActiveTab] = useState('active');
+ const [metricFilter, setMetricFilter] = useState(null);
+ const [hostSearch, setHostSearch] = useState('');
+ const [summary, setSummary] = useState({ entries: [], overall_scores: {}, upload: null });
+ const [devices, setDevices] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [selectedHost, setSelectedHost] = useState(null);
+ const [showUpload, setShowUpload] = useState(false);
+
+ const fetchSummary = useCallback(async (team) => {
+ try {
+ const res = await fetch(`${API_BASE}/compliance/summary?team=${team}`, { credentials: 'include' });
+ const data = await res.json();
+ setSummary(data);
+ } catch { /* silent */ }
+ }, []);
+
+ const fetchDevices = useCallback(async (team, tab) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`${API_BASE}/compliance/items?team=${team}&status=${tab}`, { credentials: 'include' });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Failed to load');
+ setDevices(data.devices || []);
+ } catch (err) {
+ setError(err.message);
+ setDevices([]);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ setMetricFilter(null);
+ setHostSearch('');
+ setSelectedHost(null);
+ fetchSummary(activeTeam);
+ fetchDevices(activeTeam, activeTab);
+ }, [activeTeam]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ setMetricFilter(null);
+ fetchDevices(activeTeam, activeTab);
+ }, [activeTab]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const refresh = () => {
+ fetchSummary(activeTeam);
+ fetchDevices(activeTeam, activeTab);
+ };
+
+ // In-memory filters
+ const filteredDevices = devices
+ .filter(d => !metricFilter || d.failing_metrics.some(m => m.metric_id === metricFilter))
+ .filter(d => !hostSearch || d.hostname.toLowerCase().includes(hostSearch.toLowerCase()));
+
+ const metrics = teamMetrics(summary.entries, activeTeam);
+ const lastUpload = summary.upload;
+
+ // Active tab counts (pre-filter for display)
+ const activeCount = activeTab === 'active' ? filteredDevices.length : null;
+ const resolvedCount = activeTab === 'resolved' ? filteredDevices.length : null;
+
+ return (
+
+
+ {/* ── Page header ─────────────────────────────────────────── */}
+
+
+
+ AEO Compliance
+
+
+ {lastUpload ? (
+
+ Last report: {lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}
+
+ ) : (
+ No reports uploaded
+ )}
+ {summary.overall_scores?.customer_network != null && (
+
+ Network: {pctDisplay(summary.overall_scores.customer_network)}
+
+ )}
+ {summary.overall_scores?.vertical != null && (
+
+ Vertical: {pctDisplay(summary.overall_scores.vertical)}
+
+ )}
+
+
+
+
+
+ {canWrite() && (
+
+ )}
+
+
+
+ {/* ── Team tabs ────────────────────────────────────────────── */}
+
+ {TEAMS.map(team => {
+ const isActive = activeTeam === team;
+ return (
+
+ );
+ })}
+
+
+ {/* ── Metric health cards ──────────────────────────────────── */}
+ {metrics.length > 0 ? (
+
+
+ Metric Health — click to filter
+ {metricFilter && (
+
+ )}
+
+
+ {metrics.map(entry => (
+ setMetricFilter(metricFilter === entry.metric_id ? null : entry.metric_id)}
+ />
+ ))}
+
+
+ ) : lastUpload === null ? (
+
+
+ No compliance data — upload a report to get started
+
+
+ ) : null}
+
+ {/* ── Device table ─────────────────────────────────────────── */}
+
+ {/* Table toolbar */}
+
+ {/* Active / Resolved tabs */}
+
+ {['active', 'resolved'].map(tab => {
+ const isActive = activeTab === tab;
+ return (
+
+ );
+ })}
+
+
+ {/* Hostname search */}
+
setHostSearch(e.target.value)}
+ placeholder="Search hostname…"
+ style={{
+ background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
+ borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
+ padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
+ width: '220px',
+ }}
+ onFocus={e => e.target.style.borderColor = `${TEAL}60`}
+ onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
+ />
+
+
+ {/* Column headers */}
+
+ Hostname
+ IP Address
+ Type
+ Failing Metrics
+ Seen
+
+
+
+ {/* Rows */}
+ {loading ? (
+
+
+
+ ) : error ? (
+
+ ) : filteredDevices.length === 0 ? (
+
+ {lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
+
+ ) : (
+ filteredDevices.map(device => (
+
setSelectedHost(selectedHost === device.hostname ? null : device.hostname)}
+ />
+ ))
+ )}
+
+
+ {/* ── Detail panel ─────────────────────────────────────────── */}
+ {selectedHost && (
+
setSelectedHost(null)}
+ onNoteAdded={refresh}
+ />
+ )}
+
+ {/* ── Upload modal ─────────────────────────────────────────── */}
+ {showUpload && (
+ setShowUpload(false)}
+ onUploadComplete={() => { setShowUpload(false); refresh(); }}
+ />
+ )}
+
+ );
+}
+
+function DeviceRow({ device, selected, onClick }) {
+ return (
+ { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
+ onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
+ >
+ {/* Hostname */}
+
+ {device.hostname}
+
+
+ {/* IP */}
+
+ {device.ip_address || '—'}
+
+
+ {/* Type */}
+
+ {device.device_type || '—'}
+
+
+ {/* Failing metrics */}
+
+ {device.failing_metrics.map(m => (
+
+ ))}
+
+
+ {/* Seen count */}
+
+
+
+
+ {/* Notes indicator */}
+
+ {device.has_notes && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/pages/ComplianceUploadModal.js b/frontend/src/components/pages/ComplianceUploadModal.js
new file mode 100644
index 0000000..78a3122
--- /dev/null
+++ b/frontend/src/components/pages/ComplianceUploadModal.js
@@ -0,0 +1,221 @@
+import React, { useState, useRef } from 'react';
+import { X, Upload, CheckCircle, AlertCircle, Loader, FileSpreadsheet } from 'lucide-react';
+
+const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
+
+// phase: idle → uploading → preview → committing → done | error
+export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
+ const [phase, setPhase] = useState('idle');
+ const [previewData, setPreviewData] = useState(null);
+ const [error, setError] = useState(null);
+ const [dragOver, setDragOver] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleFile = async (file) => {
+ if (!file) return;
+ if (!file.name.toLowerCase().endsWith('.xlsx')) {
+ setError('File must be an .xlsx spreadsheet');
+ return;
+ }
+
+ setPhase('uploading');
+ setError(null);
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const res = await fetch(`${API_BASE}/compliance/preview`, {
+ method: 'POST',
+ credentials: 'include',
+ body: formData,
+ });
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Upload failed');
+ }
+
+ setPreviewData(data);
+ setPhase('preview');
+ } catch (err) {
+ setError(err.message);
+ setPhase('error');
+ }
+ };
+
+ const handleCommit = async () => {
+ if (!previewData) return;
+ setPhase('committing');
+ setError(null);
+
+ try {
+ const res = await fetch(`${API_BASE}/compliance/commit`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tempFile: previewData.tempFile,
+ filename: previewData.filename,
+ report_date: previewData.report_date,
+ }),
+ });
+ const data = await res.json();
+
+ if (!res.ok) throw new Error(data.error || 'Commit failed');
+
+ setPhase('done');
+ setTimeout(onUploadComplete, 1200);
+ } catch (err) {
+ setError(err.message);
+ setPhase('error');
+ }
+ };
+
+ const TEAL = '#14B8A6';
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ Upload Report
+
+
NTS_AEO xlsx compliance report
+
+
+
+
+ {/* IDLE — drop zone */}
+ {phase === 'idle' && (
+ <>
+
fileInputRef.current?.click()}
+ onDragOver={e => { e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={e => { e.preventDefault(); setDragOver(false); handleFile(e.dataTransfer.files[0]); }}
+ style={{
+ border: `2px dashed ${dragOver ? TEAL : 'rgba(20,184,166,0.3)'}`,
+ borderRadius: '0.5rem',
+ padding: '2.5rem',
+ textAlign: 'center',
+ cursor: 'pointer',
+ background: dragOver ? `${TEAL}08` : 'transparent',
+ transition: 'all 0.2s',
+ }}>
+
+
+ Drop your xlsx file here or browse
+
+
NTS_AEO_YYYY_MM_DD.xlsx
+
+
handleFile(e.target.files[0])} />
+ {error && (
+
+ )}
+ >
+ )}
+
+ {/* UPLOADING / COMMITTING — spinner */}
+ {(phase === 'uploading' || phase === 'committing') && (
+
+
+
+ {phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
+
+
+ )}
+
+ {/* PREVIEW — diff summary + confirm */}
+ {phase === 'preview' && previewData && (
+ <>
+
+
+ {previewData.filename}
+ {previewData.report_date && {previewData.report_date}}
+
+
+ {[
+ { label: 'Recurring items', count: previewData.diff.recurring_count, color: '#94A3B8', icon: '↺' },
+ { label: 'New items', count: previewData.diff.new_count, color: '#EF4444', icon: '+' },
+ { label: 'Resolved', count: previewData.diff.resolved_count, color: '#10B981', icon: '✓' },
+ ].map(({ label, count, color, icon }) => (
+
+
+ {icon}
+ {label}
+
+ {count}
+
+ ))}
+
+
+
+
+
+
+ >
+ )}
+
+ {/* DONE */}
+ {phase === 'done' && (
+
+
+
+ Upload committed
+
+
+ )}
+
+ {/* ERROR */}
+ {phase === 'error' && (
+
+
+
{error}
+
+
+ )}
+
+
+ );
+}