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:
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user