1. ACTIVE state never populated — stats endpoint now computes ACTIVE from live findings cache count instead of querying archive table 2. CHECK constraint mismatch — migration now uses 3-state constraint (ARCHIVED, RETURNED, CLOSED) matching runtime initArchiveTables() 3. Archive filter click non-functional — handleArchiveStateClick now fetches and renders filtered archive list below summary bar 4. Hook glob pattern mismatch — changed **/migrate*.js to **/migrations/*.js so hook fires for actual migration filenames 5. Stale stats after sync — ArchiveSummaryBar polls every 60s and refreshes immediately after workflow sync via refreshKey prop
206 lines
5.7 KiB
JavaScript
206 lines
5.7 KiB
JavaScript
// ArchiveSummaryBar.js
|
|
// Displays four stat cards for archive lifecycle states: ACTIVE, ARCHIVED, RETURNED, CLOSED.
|
|
// Fetches counts from /api/ivanti/archive/stats on mount.
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Activity, Archive, RotateCcw, XCircle, Loader } from 'lucide-react';
|
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
|
|
|
const STATE_CONFIG = [
|
|
{
|
|
key: 'ACTIVE',
|
|
label: 'Active',
|
|
color: '#0EA5E9',
|
|
Icon: Activity,
|
|
},
|
|
{
|
|
key: 'ARCHIVED',
|
|
label: 'Archived',
|
|
color: '#F59E0B',
|
|
Icon: Archive,
|
|
},
|
|
{
|
|
key: 'RETURNED',
|
|
label: 'Returned',
|
|
color: '#10B981',
|
|
Icon: RotateCcw,
|
|
},
|
|
{
|
|
key: 'CLOSED',
|
|
label: 'Closed',
|
|
color: '#EF4444',
|
|
Icon: XCircle,
|
|
},
|
|
];
|
|
|
|
function StatCard({ stateKey, label, color, Icon, count, active, onClick }) {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
const isHighlighted = active || hovered;
|
|
|
|
const cardStyle = {
|
|
flex: '1 1 0',
|
|
minWidth: '140px',
|
|
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(51, 65, 85, 0.9))',
|
|
border: `2px solid ${isHighlighted ? color : `rgba(${hexToRgb(color)}, 0.3)`}`,
|
|
borderRadius: '0.5rem',
|
|
padding: '1rem',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
transform: isHighlighted ? 'translateY(-2px)' : 'translateY(0)',
|
|
boxShadow: isHighlighted
|
|
? `0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(${hexToRgb(color)}, 0.25)`
|
|
: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
};
|
|
|
|
const accentLineStyle = {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '2px',
|
|
background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
|
|
boxShadow: `0 0 8px ${color}`,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={cardStyle}
|
|
onClick={() => onClick(stateKey)}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(stateKey); } }}
|
|
aria-label={`${label}: ${count} findings. ${active ? 'Currently filtered.' : 'Click to filter.'}`}
|
|
>
|
|
<div style={accentLineStyle} />
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.625rem' }}>
|
|
<Icon
|
|
style={{
|
|
width: '16px',
|
|
height: '16px',
|
|
color: color,
|
|
filter: isHighlighted ? `drop-shadow(0 0 4px ${color})` : 'none',
|
|
}}
|
|
/>
|
|
<span style={{
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
|
fontSize: '0.7rem',
|
|
fontWeight: '600',
|
|
color: color,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.08em',
|
|
textShadow: isHighlighted ? `0 0 8px rgba(${hexToRgb(color)}, 0.5)` : 'none',
|
|
}}>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
<div style={{
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
|
fontSize: '1.75rem',
|
|
fontWeight: '700',
|
|
color: '#F8FAFC',
|
|
lineHeight: 1,
|
|
textShadow: `0 0 16px rgba(${hexToRgb(color)}, 0.3)`,
|
|
}}>
|
|
{count != null ? count : '—'}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Convert hex color to r, g, b string for use in rgba()
|
|
function hexToRgb(hex) {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return `${r}, ${g}, ${b}`;
|
|
}
|
|
|
|
export default function ArchiveSummaryBar({ onStateClick, activeFilter, refreshKey }) {
|
|
const [stats, setStats] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async () => {
|
|
setLoading(true);
|
|
setError(false);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/ivanti/archive/stats`, { credentials: 'include' });
|
|
if (res.ok && !cancelled) {
|
|
const data = await res.json();
|
|
setStats(data);
|
|
} else if (!cancelled) {
|
|
setError(true);
|
|
}
|
|
} catch {
|
|
if (!cancelled) setError(true);
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
load();
|
|
|
|
// Re-fetch every 60s so stats stay reasonably fresh after syncs
|
|
const interval = setInterval(load, 60000);
|
|
return () => { cancelled = true; clearInterval(interval); };
|
|
}, [refreshKey]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
gap: '0.5rem', padding: '1.25rem',
|
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.75rem',
|
|
}}>
|
|
<Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} />
|
|
Loading archive stats…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div style={{
|
|
padding: '1rem', textAlign: 'center',
|
|
color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem',
|
|
border: '1px dashed rgba(239, 68, 68, 0.2)', borderRadius: '0.375rem',
|
|
}}>
|
|
Unable to load archive statistics
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleClick = (state) => {
|
|
if (onStateClick) onStateClick(state);
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: '0.75rem',
|
|
marginBottom: '1.25rem',
|
|
flexWrap: 'wrap',
|
|
}}>
|
|
{STATE_CONFIG.map(({ key, label, color, Icon }) => (
|
|
<StatCard
|
|
key={key}
|
|
stateKey={key}
|
|
label={label}
|
|
color={color}
|
|
Icon={Icon}
|
|
count={stats?.[key] ?? 0}
|
|
active={activeFilter === key}
|
|
onClick={handleClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|