/** * LoaderModal — Granite Team_Device Loader Sheet Generator * * Generates a properly formatted xlsx for upload to SNIP XperLoad. * Supports queue-initiated mode (pre-populated devices) and standalone mode (paste IPs). */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { FileSpreadsheet, Download, X, RefreshCw, Plus, Trash2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'; import { LOADER_COLUMNS, COLUMN_GROUPS, OPERATION_TYPES, getRequiredColumns, getColumnsByGroup, } from '../utils/graniteLoaderConfig'; import { generateLoaderXlsx, generateFilename } from '../utils/graniteLoaderExport'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const OVERLAY = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', }; const MODAL = { background: '#1E293B', borderRadius: '0.75rem', border: '1px solid #334155', width: '90vw', maxWidth: '1100px', maxHeight: '90vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', }; const HEADER = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem 1.25rem', borderBottom: '1px solid #334155', }; const BODY = { flex: 1, overflow: 'auto', padding: '1.25rem' }; const FOOTER = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1rem 1.25rem', borderTop: '1px solid #334155', }; const INPUT = { background: '#0F172A', border: '1px solid #334155', borderRadius: '0.375rem', color: '#E2E8F0', padding: '0.4rem 0.6rem', fontSize: '0.75rem', width: '100%', }; const BTN = { padding: '0.5rem 1rem', borderRadius: '0.375rem', border: 'none', fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer', }; const BTN_PRIMARY = { ...BTN, background: '#7C3AED', color: '#fff' }; const BTN_SECONDARY = { ...BTN, background: '#334155', color: '#E2E8F0' }; const BTN_SUCCESS = { ...BTN, background: '#10B981', color: '#fff' }; export default function LoaderModal({ isOpen, onClose, initialDevices }) { // --- State --- const [operationType, setOperationType] = useState('Change'); const [selectedColumns, setSelectedColumns] = useState(new Set()); const [devices, setDevices] = useState([]); const [bulkDefaults, setBulkDefaults] = useState({}); const [overrides, setOverrides] = useState({}); const [editingCell, setEditingCell] = useState(null); const [editValue, setEditValue] = useState(''); const [enriching, setEnriching] = useState(false); const [enrichErrors, setEnrichErrors] = useState([]); const [cardConfigured, setCardConfigured] = useState(false); const [pasteInput, setPasteInput] = useState(''); const [expandedGroups, setExpandedGroups] = useState(new Set(['Identification', 'Responsible Org'])); const [validationWarnings, setValidationWarnings] = useState([]); // --- Initialize --- useEffect(() => { if (!isOpen) return; // Check CARD status fetch(`${API_BASE}/card/status`, { credentials: 'include' }) .then(r => r.json()) .then(d => setCardConfigured(d.configured === true)) .catch(() => setCardConfigured(false)); }, [isOpen]); // Populate devices from initialDevices or reset useEffect(() => { if (!isOpen) return; if (initialDevices && initialDevices.length > 0) { setDevices(initialDevices.map(d => ({ IPV4_ADDRESS: d.ip_address || '', EQUIP_NAME: d.hostname || '', }))); } else { setDevices([]); } setOverrides({}); setBulkDefaults({}); setEnrichErrors([]); setValidationWarnings([]); }, [isOpen, initialDevices]); // Auto-select required columns + useful defaults when operation type changes useEffect(() => { const required = getRequiredColumns(operationType); setSelectedColumns(prev => { const next = new Set(prev); required.forEach(id => next.add(id)); // Always include these useful columns by default next.add('IPV4_ADDRESS'); next.add('EQUIP_NAME'); next.add('RESPONSIBLE_TEAM'); return next; }); }, [operationType]); // --- Column selection --- const toggleColumn = useCallback((colId) => { const required = getRequiredColumns(operationType); if (required.includes(colId)) return; // Can't deselect required setSelectedColumns(prev => { const next = new Set(prev); if (next.has(colId)) next.delete(colId); else next.add(colId); return next; }); }, [operationType]); const toggleGroup = useCallback((group) => { setExpandedGroups(prev => { const next = new Set(prev); if (next.has(group)) next.delete(group); else next.add(group); return next; }); }, []); // --- Ordered selected columns (canonical order) --- const orderedColumns = useMemo(() => { return LOADER_COLUMNS.filter(col => selectedColumns.has(col.id)); }, [selectedColumns]); // --- Resolve cell value (override > bulk default > device value > empty) --- const getCellValue = useCallback((rowIdx, colId) => { if (overrides[rowIdx] && overrides[rowIdx][colId] !== undefined) { return overrides[rowIdx][colId]; } if (bulkDefaults[colId] !== undefined && bulkDefaults[colId] !== '') { return bulkDefaults[colId]; } return devices[rowIdx]?.[colId] || ''; }, [overrides, bulkDefaults, devices]); // --- Cell editing --- const startEdit = (rowIdx, colId) => { setEditingCell({ rowIdx, colId }); setEditValue(getCellValue(rowIdx, colId)); }; const commitEdit = () => { if (!editingCell) return; const { rowIdx, colId } = editingCell; const currentBulk = bulkDefaults[colId] || ''; const currentDevice = devices[rowIdx]?.[colId] || ''; // Only store override if different from bulk default and device value if (editValue !== currentBulk || currentDevice) { setOverrides(prev => ({ ...prev, [rowIdx]: { ...(prev[rowIdx] || {}), [colId]: editValue }, })); } setEditingCell(null); }; const clearOverride = (rowIdx, colId) => { setOverrides(prev => { const next = { ...prev }; if (next[rowIdx]) { const row = { ...next[rowIdx] }; delete row[colId]; if (Object.keys(row).length === 0) delete next[rowIdx]; else next[rowIdx] = row; } return next; }); }; // --- Bulk default --- const setBulkDefault = (colId, value) => { setBulkDefaults(prev => ({ ...prev, [colId]: value })); }; // --- Paste IPs (standalone mode) --- const loadPastedIps = () => { const lines = pasteInput.split(/[\n,]+/).map(s => s.trim()).filter(Boolean); const newDevices = lines.slice(0, 200).map(ip => ({ IPV4_ADDRESS: ip, EQUIP_NAME: '' })); setDevices(newDevices); setOverrides({}); setPasteInput(''); }; const addRow = () => { setDevices(prev => [...prev, { IPV4_ADDRESS: '', EQUIP_NAME: '' }]); }; const removeRow = (idx) => { setDevices(prev => prev.filter((_, i) => i !== idx)); setOverrides(prev => { const next = {}; Object.entries(prev).forEach(([k, v]) => { const ki = parseInt(k, 10); if (ki < idx) next[ki] = v; else if (ki > idx) next[ki - 1] = v; }); return next; }); }; // --- CARD Enrichment --- const enrichFromCard = async () => { const ips = devices.map(d => d.IPV4_ADDRESS).filter(Boolean); if (ips.length === 0) return; setEnriching(true); setEnrichErrors([]); try { const resp = await fetch(`${API_BASE}/card/enrich-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ips }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); setEnrichErrors([{ ip: 'all', error: err.error || `HTTP ${resp.status}` }]); setEnriching(false); return; } const data = await resp.json(); const errors = []; // Map results back to devices setDevices(prev => prev.map((device, idx) => { const result = data.results.find(r => r.ip === device.IPV4_ADDRESS); if (!result || !result.found) { if (result) errors.push({ ip: result.ip, error: result.error || 'Not found' }); return device; } // Only populate fields that aren't already overridden by the user const updated = { ...device }; const rowOverrides = overrides[idx] || {}; if (result.equip_inst_id && !rowOverrides.EQUIP_INST_ID && !device.EQUIP_INST_ID) { updated.EQUIP_INST_ID = result.equip_inst_id; } if (result.hostname && !rowOverrides.EQUIP_NAME && !device.EQUIP_NAME) { updated.EQUIP_NAME = result.hostname; } if (result.site_name && !rowOverrides.SITE_NAME && !device.SITE_NAME) { updated.SITE_NAME = result.site_name; } if (result.mgmt_ip_asn && !rowOverrides.MGMT_IP_ASN && !device.MGMT_IP_ASN) { updated.MGMT_IP_ASN = result.mgmt_ip_asn; } if (result.responsible_team && !rowOverrides.RESPONSIBLE_TEAM && !device.RESPONSIBLE_TEAM) { updated.RESPONSIBLE_TEAM = result.responsible_team; } if (result.equip_status && !rowOverrides.EQUIP_STATUS && !device.EQUIP_STATUS) { updated.EQUIP_STATUS = result.equip_status; } return updated; })); setEnrichErrors(errors); } catch (err) { setEnrichErrors([{ ip: 'all', error: err.message }]); } setEnriching(false); }; // --- Validation --- const validate = () => { const required = getRequiredColumns(operationType); const warnings = []; devices.forEach((_, rowIdx) => { required.forEach(colId => { if (colId === 'DELETE') return; // Auto-filled const val = getCellValue(rowIdx, colId); if (!val) { warnings.push({ rowIdx, colId }); } }); }); setValidationWarnings(warnings); return warnings; }; // --- Download --- const handleDownload = () => { const warnings = validate(); // Build final rows const finalRows = devices.map((_, rowIdx) => { const row = {}; orderedColumns.forEach(col => { row[col.id] = getCellValue(rowIdx, col.id); }); return row; }); // Determine team name for filename (from bulk default or first row) const teamName = bulkDefaults.RESPONSIBLE_TEAM || finalRows[0]?.RESPONSIBLE_TEAM || ''; const blob = generateLoaderXlsx({ operationType, columnIds: orderedColumns.map(c => c.id), rows: finalRows, }); const filename = generateFilename(operationType, teamName); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Keep warnings visible but don't block download if (warnings.length > 0) { // Warnings already displayed in UI } }; // --- Render --- if (!isOpen) return null; const requiredCols = getRequiredColumns(operationType); const isStandalone = !initialDevices || initialDevices.length === 0; const missingCount = validationWarnings.length; const isCellWarning = (rowIdx, colId) => validationWarnings.some(w => w.rowIdx === rowIdx && w.colId === colId); const isOverridden = (rowIdx, colId) => overrides[rowIdx] && overrides[rowIdx][colId] !== undefined; return (
| # | {orderedColumns.map(col => ({col.id} | ))}|
|---|---|---|
| {rowIdx + 1} | {orderedColumns.map(col => { const value = getCellValue(rowIdx, col.id); const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colId === col.id; const hasOverride = isOverridden(rowIdx, col.id); const hasWarning = isCellWarning(rowIdx, col.id); return ( !isEditing && startEdit(rowIdx, col.id)}
>
{isEditing ? (
setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
autoFocus
/>
) : (
{hasOverride && ●}
{value || '—'}
{hasOverride && (
)}
)}
|
);
})}