Add multi-metric note selection to compliance detail panel
This commit is contained in:
@@ -42,7 +42,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [noteMetric, setNoteMetric] = useState('');
|
||||
const [selectedMetrics, setSelectedMetrics] = useState([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [noteError, setNoteError] = useState(null);
|
||||
|
||||
@@ -55,9 +55,9 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to load device');
|
||||
setDetail(data);
|
||||
|
||||
// Default note metric to first active failing metric
|
||||
// Default selected metrics to first active failing metric
|
||||
const firstActive = (data.metrics || []).find(m => m.status === 'active');
|
||||
if (firstActive) setNoteMetric(firstActive.metric_id);
|
||||
if (firstActive) setSelectedMetrics([firstActive.metric_id]);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -68,7 +68,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
useEffect(() => { fetchDetail(); }, [fetchDetail]);
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!noteText.trim() || !noteMetric) return;
|
||||
if (!noteText.trim() || selectedMetrics.length === 0) return;
|
||||
setSubmitting(true);
|
||||
setNoteError(null);
|
||||
try {
|
||||
@@ -76,7 +76,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }),
|
||||
body: JSON.stringify({ hostname, metric_ids: selectedMetrics, note: noteText.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save note');
|
||||
@@ -194,39 +194,115 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
{detail.notes.length === 0 && (
|
||||
<div style={{ color: '#334155', fontSize: '0.75rem', fontStyle: 'italic', marginBottom: '0.75rem' }}>No notes yet</div>
|
||||
)}
|
||||
{detail.notes.map(n => (
|
||||
<div key={n.id} style={{
|
||||
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
|
||||
<MetricChip metricId={n.metric_id} category={activeMetrics.find(m => m.metric_id === n.metric_id)?.category || ''} />
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>
|
||||
{n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)}
|
||||
</span>
|
||||
{(() => {
|
||||
// Build a lookup map for metric categories (active + resolved)
|
||||
const metricMap = {};
|
||||
(detail.metrics || []).forEach(m => { metricMap[m.metric_id] = m.category; });
|
||||
|
||||
// Group notes by group_id, preserving reverse chronological order
|
||||
const grouped = [];
|
||||
const seen = new Set();
|
||||
// detail.notes is already sorted newest-first from the API
|
||||
for (const n of detail.notes) {
|
||||
const gid = n.group_id;
|
||||
if (!gid) {
|
||||
// Legacy note without group_id — render individually
|
||||
grouped.push({ key: `note-${n.id}`, notes: [n], note: n.note, created_by: n.created_by, created_at: n.created_at });
|
||||
} else if (!seen.has(gid)) {
|
||||
seen.add(gid);
|
||||
const group = detail.notes.filter(x => x.group_id === gid);
|
||||
grouped.push({ key: `group-${gid}`, notes: group, note: group[0].note, created_by: group[0].created_by, created_at: group[0].created_at });
|
||||
}
|
||||
}
|
||||
|
||||
return grouped.map(g => (
|
||||
<div key={g.key} style={{
|
||||
marginBottom: '0.75rem', padding: '0.625rem 0.75rem',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{g.notes.map(n => (
|
||||
<MetricChip key={n.id} metricId={n.metric_id} category={metricMap[n.metric_id] || ''} />
|
||||
))}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', flexShrink: 0, marginLeft: '0.5rem', whiteSpace: 'nowrap' }}>
|
||||
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{g.note}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: 1.5 }}>{n.note}</div>
|
||||
</div>
|
||||
))}
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Add note */}
|
||||
<div style={{ marginTop: 'auto', paddingTop: '0.75rem', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
{activeMetrics.length > 1 && (
|
||||
<select
|
||||
value={noteMetric}
|
||||
onChange={e => setNoteMetric(e.target.value)}
|
||||
style={{
|
||||
width: '100%', marginBottom: '0.5rem',
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.25rem', color: '#CBD5E1',
|
||||
padding: '0.4rem 0.5rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||
}}>
|
||||
{activeMetrics.map(m => (
|
||||
<option key={m.metric_id} value={m.metric_id}>{m.metric_id} — {m.category}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{activeMetrics.length > 1 && (() => {
|
||||
const allSelected = activeMetrics.length > 0 && activeMetrics.every(m => selectedMetrics.includes(m.metric_id));
|
||||
return (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
|
||||
Metrics
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelectedMetrics([activeMetrics[0].metric_id]);
|
||||
} else {
|
||||
setSelectedMetrics(activeMetrics.map(m => m.metric_id));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace',
|
||||
color: TEAL, padding: 0,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.7'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{activeMetrics.map(m => {
|
||||
const isSelected = selectedMetrics.includes(m.metric_id);
|
||||
const color = categoryColor(m.category);
|
||||
return (
|
||||
<button
|
||||
key={m.metric_id}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
if (selectedMetrics.length > 1) {
|
||||
setSelectedMetrics(selectedMetrics.filter(id => id !== m.metric_id));
|
||||
}
|
||||
} else {
|
||||
setSelectedMetrics([...selectedMetrics, m.metric_id]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: isSelected ? `${color}25` : `${color}08`,
|
||||
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
|
||||
borderRadius: '0.25rem',
|
||||
color: isSelected ? color : `${color}90`,
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
|
||||
}}
|
||||
>
|
||||
{m.metric_id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<textarea
|
||||
value={noteText}
|
||||
@@ -244,13 +320,13 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.25)'}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAddNote(); }}
|
||||
/>
|
||||
<button onClick={handleAddNote} disabled={!noteText.trim() || submitting}
|
||||
<button onClick={handleAddNote} disabled={!noteText.trim() || selectedMetrics.length === 0 || submitting}
|
||||
style={{
|
||||
padding: '0.5rem 0.625rem', flexShrink: 0,
|
||||
background: noteText.trim() ? `${TEAL}20` : 'transparent',
|
||||
border: `1px solid ${noteText.trim() ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||||
borderRadius: '0.375rem', color: noteText.trim() ? TEAL : '#334155',
|
||||
cursor: noteText.trim() ? 'pointer' : 'default', transition: 'all 0.15s',
|
||||
background: (noteText.trim() && selectedMetrics.length > 0) ? `${TEAL}20` : 'transparent',
|
||||
border: `1px solid ${(noteText.trim() && selectedMetrics.length > 0) ? TEAL : 'rgba(20,184,166,0.2)'}`,
|
||||
borderRadius: '0.375rem', color: (noteText.trim() && selectedMetrics.length > 0) ? TEAL : '#334155',
|
||||
cursor: (noteText.trim() && selectedMetrics.length > 0) ? 'pointer' : 'default', transition: 'all 0.15s',
|
||||
}}>
|
||||
{submitting
|
||||
? <Loader style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} />
|
||||
|
||||
Reference in New Issue
Block a user