Add Granite Loader export to supplemental hygiene section

Select devices via checkboxes in the Granite Hygiene device table and
generate Granite Loader sheets directly — equip_inst_id is already
present from the supplemental data so no CARD enrichment needed.
This commit is contained in:
Jordan Ramos
2026-08-04 12:57:59 -06:00
parent eb0d55a35b
commit 4852ea3c1a

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ChevronDown, ChevronRight, Search, Database } from 'lucide-react';
import { ChevronDown, ChevronRight, Search, Database, FileSpreadsheet } from 'lucide-react';
import SupplementalDetailPanel from './SupplementalDetailPanel';
import LoaderModal from '../LoaderModal';
// ⚠️ CONVENTION: Use relative API path — fallback should be '/api', not 'http://localhost:3001/api'
// ⚠️ CONVENTION: Fallback should be '/api' (relative), not 'http://localhost:3001/api' — Express serves both API and frontend on the same origin in production
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
const SHEET_COLORS = {
@@ -25,6 +26,8 @@ export default function GraniteHygieneSection({ activeTeam, teamScope: _teamScop
const [loading, setLoading] = useState(false);
const [devicesLoading, setDevicesLoading] = useState(false);
const [selectedHost, setSelectedHost] = useState(null);
const [selectedDevices, setSelectedDevices] = useState(new Set());
const [showLoaderModal, setShowLoaderModal] = useState(false);
const fetchSummary = useCallback(async () => {
setLoading(true);
@@ -164,15 +167,26 @@ export default function GraniteHygieneSection({ activeTeam, teamScope: _teamScop
({devicesTotal.toLocaleString()} devices)
</span>
</span>
<div style={styles.searchBox}>
<Search style={{ width: '12px', height: '12px', color: '#475569' }} />
<input
type="text"
placeholder="Search hostname..."
value={devicesSearch}
onChange={e => handleSearch(e.target.value)}
style={styles.searchInput}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{selectedDevices.size > 0 && (
<button
onClick={() => setShowLoaderModal(true)}
style={styles.loaderBtn}
>
<FileSpreadsheet style={{ width: '12px', height: '12px' }} />
Granite Loader ({selectedDevices.size})
</button>
)}
<div style={styles.searchBox}>
<Search style={{ width: '12px', height: '12px', color: '#475569' }} />
<input
type="text"
placeholder="Search hostname..."
value={devicesSearch}
onChange={e => handleSearch(e.target.value)}
style={styles.searchInput}
/>
</div>
</div>
</div>
@@ -186,6 +200,20 @@ export default function GraniteHygieneSection({ activeTeam, teamScope: _teamScop
<table style={styles.table}>
<thead>
<tr>
<th style={{ ...styles.th, width: '32px', textAlign: 'center' }}>
<input
type="checkbox"
checked={devices.length > 0 && devices.every(d => selectedDevices.has(d.hostname))}
onChange={() => {
if (devices.every(d => selectedDevices.has(d.hostname))) {
setSelectedDevices(new Set());
} else {
setSelectedDevices(new Set(devices.map(d => d.hostname)));
}
}}
style={{ accentColor: '#14B8A6', cursor: 'pointer' }}
/>
</th>
<th style={styles.th}>Hostname</th>
<th style={styles.th}>IP</th>
<th style={styles.th}>Function</th>
@@ -204,6 +232,21 @@ export default function GraniteHygieneSection({ activeTeam, teamScope: _teamScop
onMouseEnter={e => e.currentTarget.style.background = 'rgba(20,184,166,0.05)'}
onMouseLeave={e => e.currentTarget.style.background = i % 2 === 0 ? '' : 'rgba(15,23,42,0.3)'}
>
<td style={{ ...styles.td, textAlign: 'center', width: '32px' }} onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedDevices.has(d.hostname)}
onChange={() => {
setSelectedDevices(prev => {
const next = new Set(prev);
if (next.has(d.hostname)) next.delete(d.hostname);
else next.add(d.hostname);
return next;
});
}}
style={{ accentColor: '#14B8A6', cursor: 'pointer' }}
/>
</td>
<td style={{ ...styles.td, color: '#E2E8F0', fontWeight: '500' }}>{d.hostname}</td>
<td style={styles.td}>{d.ip_address || d.ipv6_address || '—'}</td>
<td style={styles.td}>{d.device_function || '—'}</td>
@@ -255,6 +298,18 @@ export default function GraniteHygieneSection({ activeTeam, teamScope: _teamScop
onSaved={() => { fetchDevices(activeSheet, devicesPage, devicesSearch); fetchSummary(); }}
/>
)}
{/* Granite Loader Modal */}
<LoaderModal
isOpen={showLoaderModal}
onClose={() => { setShowLoaderModal(false); setSelectedDevices(new Set()); }}
initialDevices={showLoaderModal ? devices.filter(d => selectedDevices.has(d.hostname)).map(d => ({
ip_address: d.ip_address || '',
hostname: d.hostname || '',
host_id: null,
equip_inst_id: d.equip_inst_id || '',
})) : null}
/>
</div>
);
}
@@ -326,4 +381,11 @@ const styles = {
padding: '0.25rem 0.625rem', fontSize: '0.7rem', cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
},
loaderBtn: {
display: 'flex', alignItems: 'center', gap: '0.35rem',
background: 'rgba(124, 58, 237, 0.12)', border: '1px solid rgba(124, 58, 237, 0.5)',
borderRadius: '0.375rem', padding: '0.3rem 0.625rem',
color: '#A78BFA', cursor: 'pointer', fontSize: '0.7rem',
fontFamily: "'JetBrains Mono', monospace", fontWeight: '600',
},
};