Add Granite Loader Sheet generator with CARD enrichment

Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
  operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
  the xlsx library
- Add LoaderModal component with operation type selection, column
  checkboxes, bulk defaults with per-row overrides, editable preview
  table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
  CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
  action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
This commit is contained in:
Jordan Ramos
2026-05-27 17:18:36 -06:00
parent 1903e41088
commit fe82362afa
7 changed files with 1071 additions and 8 deletions

1
.gitignore vendored
View File

@@ -71,3 +71,4 @@ docs/data-exports/
# Python cache
__pycache__/
docs/Team_Device Loader.xlsx

View File

@@ -52,7 +52,14 @@ function handleCardError(err, res) {
function createCardApiRouter() {
const router = express.Router();
// GET /status
/**
* GET /status
*
* Returns whether the CARD API integration is configured.
*
* @response 200 - { configured: true }
* @response 503 - { configured: false, error: string, missingVars: string[] }
*/
router.get('/status', requireAuth(), (req, res) => {
if (!isConfigured) {
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
@@ -60,7 +67,14 @@ function createCardApiRouter() {
res.json({ configured: true });
});
// GET /teams
/**
* GET /teams
*
* Returns the list of teams from the CARD API.
*
* @response 200 - Array of team objects from CARD
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
@@ -82,7 +96,19 @@ function createCardApiRouter() {
}
});
// GET /teams/:teamName/assets
/**
* GET /teams/:teamName/assets
*
* Returns paginated assets for a team filtered by disposition.
*
* @param {string} teamName - Team name (path parameter)
* @query {string} disposition - Required. Asset disposition filter.
* @query {number} [page] - Page number for pagination.
* @query {number} [page_size=50] - Number of results per page.
* @response 200 - { assets: object[], total: number, ... } from CARD
* @response 400 - { error: string } — missing disposition
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
@@ -133,7 +159,15 @@ function createCardApiRouter() {
}
});
// GET /owner/:assetId
/**
* GET /owner/:assetId
*
* Returns the CARD owner record for a given asset ID.
*
* @param {string} assetId - CARD asset identifier (path parameter)
* @response 200 - CARD owner/asset object
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
@@ -156,7 +190,23 @@ function createCardApiRouter() {
}
});
// POST /queue/:queueItemId/confirm
/**
* POST /queue/:queueItemId/confirm
*
* Confirms ownership of a CARD asset for a queue item. Fetches the owner
* record to obtain the update_token, then calls the CARD confirm endpoint.
* Marks the queue item as complete on success.
*
* @param {string} queueItemId - Queue item ID (path parameter)
* @body {string} teamName - Team name to confirm ownership for (required)
* @body {string} assetId - CARD asset identifier (required)
* @body {string} [comment] - Optional comment for the confirmation
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } — missing fields or item not pending
* @response 404 - { error: string } — queue item not found
* @response 502 - { error: string } — CARD API failure
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
@@ -232,7 +282,23 @@ function createCardApiRouter() {
}
});
// POST /queue/:queueItemId/decline
/**
* POST /queue/:queueItemId/decline
*
* Declines ownership of a CARD asset for a queue item. Fetches the owner
* record to obtain the update_token, then calls the CARD decline endpoint.
* Marks the queue item as complete on success.
*
* @param {string} queueItemId - Queue item ID (path parameter)
* @body {string} teamName - Team name declining ownership (required)
* @body {string} assetId - CARD asset identifier (required)
* @body {string} [comment] - Optional comment for the decline
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } — missing fields or item not pending
* @response 404 - { error: string } — queue item not found
* @response 502 - { error: string } — CARD API failure
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
@@ -308,7 +374,23 @@ function createCardApiRouter() {
}
});
// POST /queue/:queueItemId/redirect
/**
* POST /queue/:queueItemId/redirect
*
* Redirects a CARD asset from one team to another for a queue item. Fetches
* the owner record to obtain the update_token, then calls the CARD redirect
* endpoint. Marks the queue item as complete on success.
*
* @param {string} queueItemId - Queue item ID (path parameter)
* @body {string} fromTeam - Current owning team (required)
* @body {string} toTeam - Target team to redirect to (required)
* @body {string} assetId - CARD asset identifier (required)
* @response 200 - { success: true, cardResponse: object }
* @response 400 - { error: string } — missing fields or item not pending
* @response 404 - { error: string } — queue item not found
* @response 502 - { error: string } — CARD API failure
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
@@ -387,7 +469,163 @@ function createCardApiRouter() {
}
});
/**
* POST /enrich-batch
*
* Batch lookup IPs in CARD to extract Granite loader fields. Tries each IP
* with known asset ID suffixes (CTEC, NATL, CHTR, etc.) and falls back to
* bare IP lookup. Returns enrichment results for each IP.
*
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: null, equip_status?: string|null, error?: string }
* @response 400 - { error: string } — invalid or empty ips array, or exceeds 200
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
*/
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
if (!isConfigured) {
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
}
const { ips } = req.body || {};
if (!Array.isArray(ips) || ips.length === 0) {
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' });
}
if (ips.length > 200) {
return res.status(400).json({ error: 'Maximum 200 IPs per request.' });
}
// Known CARD asset ID suffixes to try
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
const results = [];
let enrichedCount = 0;
let notFoundCount = 0;
for (const ip of ips) {
if (!ip || typeof ip !== 'string') {
results.push({ ip: ip || '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
notFoundCount++;
continue;
}
const trimmedIp = ip.trim();
let found = false;
let enriched = null;
// Try owner lookup with each suffix
for (const suffix of SUFFIXES) {
try {
const assetId = `${trimmedIp}-${suffix}`;
const ownerResult = await getOwner(assetId);
if (ownerResult.ok) {
const asset = JSON.parse(ownerResult.body);
enriched = extractGraniteFields(asset, trimmedIp);
found = true;
break;
}
} catch (_) {
// Continue to next suffix
}
}
// Try without suffix (bare IP)
if (!found) {
try {
const ownerResult = await getOwner(trimmedIp);
if (ownerResult.ok) {
const asset = JSON.parse(ownerResult.body);
enriched = extractGraniteFields(asset, trimmedIp);
found = true;
}
} catch (_) {
// Not found
}
}
if (found && enriched) {
results.push({ ip: trimmedIp, found: true, ...enriched });
enrichedCount++;
} else {
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
notFoundCount++;
}
}
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length });
});
return router;
}
/**
* Extract Granite-relevant fields from a CARD asset record.
*/
function extractGraniteFields(asset, ip) {
const ncim = asset.ncim_discovery || [];
const granite = asset.netops_granite_allips || [];
const iseGranite = asset.ise_granite_equipment || [];
const flags = (asset.card_flags && asset.card_flags[0]) || {};
const qualys = asset.qualys_hosts || [];
const ivanti = asset.ivanti_assets || [];
// EQUIP_INST_ID — primary from ncim_discovery, fallbacks
let equip_inst_id = null;
let site_name = null;
let responsible_team = null;
let hostname = null;
if (ncim.length > 0) {
equip_inst_id = ncim[0].EQUIP_INST_ID || null;
responsible_team = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
site_name = ncim[0].SITE_NAME || ncim[0].SITENAME || null;
hostname = ncim[0].HOSTNAME || null;
}
if (!equip_inst_id && Array.isArray(granite) && granite.length > 0) {
equip_inst_id = granite[0].EQUIP_INST_ID || null;
if (!site_name) site_name = granite[0].SITE_NAME || null;
if (!responsible_team) responsible_team = granite[0].RESPONSIBLE_TEAM || null;
}
if (!equip_inst_id && Array.isArray(iseGranite) && iseGranite.length > 0) {
equip_inst_id = iseGranite[0].EQUIP_INST_ID || null;
}
// Hostname fallbacks
if (!hostname) {
hostname = (flags.CARD_HOSTNAME && flags.CARD_HOSTNAME[0])
|| (qualys.length > 0 && qualys[0].HOSTNAME)
|| (ivanti.length > 0 && ivanti[0].hostName)
|| null;
}
// ASN
const mgmt_ip_asn = (flags.CARD_ASN) || null;
// Equipment class — always S (Shelf) from CARD context
const equipment_class = 'S';
// Equip status from flags
const equip_status = (flags.status) || null;
// Equip template — not typically in CARD data, leave null
const equip_template = null;
// Confirmed team from owner record
const confirmed_team = asset.owner && asset.owner.confirmed
? asset.owner.confirmed.name : null;
return {
equip_inst_id: equip_inst_id ? String(equip_inst_id) : null,
hostname,
site_name,
mgmt_ip_asn: mgmt_ip_asn ? String(mgmt_ip_asn) : null,
responsible_team: responsible_team || confirmed_team || null,
equipment_class,
equip_template,
equip_status,
};
}
module.exports = createCardApiRouter;

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* CARD API Connectivity Test
* Tests: token acquisition → teams list → sample asset lookup
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const { isConfigured, missingVars, testConnection, getTeams } = require('../helpers/cardApi');
async function main() {
console.log('=== CARD API Connectivity Test ===');
console.log(`Timestamp: ${new Date().toISOString()}`);
console.log(`Target: ${process.env.CARD_API_URL}`);
console.log(`User: ${process.env.CARD_API_USER}`);
console.log(`TLS Skip: ${process.env.CARD_SKIP_TLS}`);
console.log('');
if (!isConfigured) {
console.error('FAIL: CARD API not configured. Missing:', missingVars.join(', '));
process.exit(1);
}
// Step 1: Token acquisition
console.log('1. Acquiring Bearer token...');
const connResult = await testConnection();
if (!connResult.ok) {
console.error(' FAIL:', connResult.error);
process.exit(1);
}
console.log(' OK — token acquired:', connResult.token);
// Step 2: List teams
console.log('2. Fetching teams (GET /api/v1/teams)...');
const teamsResult = await getTeams();
console.log(' Status:', teamsResult.status);
if (!teamsResult.ok) {
console.error(' FAIL:', teamsResult.body.substring(0, 300));
process.exit(1);
}
let teams;
try {
teams = JSON.parse(teamsResult.body);
} catch (e) {
console.error(' FAIL: Could not parse response:', teamsResult.body.substring(0, 200));
process.exit(1);
}
if (Array.isArray(teams)) {
console.log(` OK — ${teams.length} teams found`);
const sample = teams.slice(0, 8);
sample.forEach(t => {
const name = t.name || t.team_name || t.teamName || JSON.stringify(t).substring(0, 60);
console.log(`${name}`);
});
} else {
console.log(' Response structure:', Object.keys(teams).join(', '));
console.log(' Preview:', JSON.stringify(teams).substring(0, 200));
}
console.log('');
console.log('=== RESULT: PASS — CARD API is reachable and authenticated ===');
}
main().catch(err => {
console.error('ERROR:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,574 @@
/**
* 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 when operation type changes
useEffect(() => {
const required = getRequiredColumns(operationType);
setSelectedColumns(prev => {
const next = new Set(prev);
required.forEach(id => next.add(id));
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 (
<div style={OVERLAY} onClick={onClose}>
<div style={MODAL} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={HEADER}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FileSpreadsheet style={{ width: '18px', height: '18px', color: '#7C3AED' }} />
<span style={{ fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0' }}>Generate Granite Loader Sheet</span>
<span style={{ fontSize: '0.7rem', color: '#64748B' }}>({devices.length} devices)</span>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<X style={{ width: '18px', height: '18px' }} />
</button>
</div>
{/* Body */}
<div style={BODY}>
{/* Top controls row */}
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginBottom: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.65rem', color: '#94A3B8', display: 'block', marginBottom: '0.2rem' }}>Operation</label>
<select style={{ ...INPUT, width: '140px', cursor: 'pointer' }} value={operationType} onChange={e => setOperationType(e.target.value)}>
{OPERATION_TYPES.map(op => <option key={op} value={op}>{op}</option>)}
</select>
</div>
{cardConfigured && (
<button style={{ ...BTN_SECONDARY, display: 'flex', alignItems: 'center', gap: '0.3rem' }} onClick={enrichFromCard} disabled={enriching || devices.length === 0}>
<RefreshCw style={{ width: '12px', height: '12px', animation: enriching ? 'spin 1s linear infinite' : 'none' }} />
{enriching ? 'Enriching...' : 'Enrich from CARD'}
</button>
)}
</div>
{/* Standalone: paste IPs */}
{isStandalone && devices.length === 0 && (
<div style={{ marginBottom: '1rem', padding: '1rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
<label style={{ fontSize: '0.7rem', color: '#94A3B8', display: 'block', marginBottom: '0.3rem' }}>Paste IP addresses (one per line or comma-separated)</label>
<textarea
style={{ ...INPUT, height: '80px', resize: 'vertical', fontFamily: 'monospace' }}
value={pasteInput}
onChange={e => setPasteInput(e.target.value)}
placeholder="10.240.78.110&#10;10.240.78.111&#10;172.16.5.20"
/>
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem' }}>
<button style={BTN_PRIMARY} onClick={loadPastedIps} disabled={!pasteInput.trim()}>Load IPs</button>
</div>
</div>
)}
{/* Enrich errors */}
{enrichErrors.length > 0 && (
<div style={{ marginBottom: '0.75rem', padding: '0.5rem 0.75rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '0.375rem' }}>
<div style={{ fontSize: '0.7rem', color: '#EF4444', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<AlertCircle style={{ width: '12px', height: '12px' }} />
{enrichErrors.length === 1 && enrichErrors[0].ip === 'all'
? enrichErrors[0].error
: `${enrichErrors.length} device(s) not found in CARD`}
</div>
</div>
)}
{/* Column selection */}
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', marginBottom: '0.5rem', fontWeight: '600' }}>Columns</div>
{COLUMN_GROUPS.map(group => {
const cols = getColumnsByGroup(group);
const selectedInGroup = cols.filter(c => selectedColumns.has(c.id)).length;
const expanded = expandedGroups.has(group);
return (
<div key={group} style={{ marginBottom: '0.25rem' }}>
<div
style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer', padding: '0.2rem 0', userSelect: 'none' }}
onClick={() => toggleGroup(group)}
>
{expanded ? <ChevronDown style={{ width: '12px', height: '12px', color: '#64748B' }} /> : <ChevronRight style={{ width: '12px', height: '12px', color: '#64748B' }} />}
<span style={{ fontSize: '0.7rem', color: '#E2E8F0' }}>{group}</span>
<span style={{ fontSize: '0.6rem', color: '#64748B' }}>({selectedInGroup} selected)</span>
</div>
{expanded && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', paddingLeft: '1.2rem', marginTop: '0.2rem' }}>
{cols.map(col => {
const isRequired = requiredCols.includes(col.id);
const isChecked = selectedColumns.has(col.id);
return (
<label key={col.id} style={{ display: 'flex', alignItems: 'center', gap: '0.2rem', fontSize: '0.65rem', color: isRequired ? '#A78BFA' : '#94A3B8', cursor: isRequired ? 'default' : 'pointer' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleColumn(col.id)}
disabled={isRequired}
style={{ accentColor: '#7C3AED' }}
/>
{col.label.length > 30 ? col.id : col.label}
{isRequired && <span style={{ fontSize: '0.55rem', color: '#7C3AED' }}>*</span>}
</label>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* Bulk defaults */}
{orderedColumns.length > 0 && devices.length > 0 && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: '#0F172A', borderRadius: '0.5rem', border: '1px solid #334155' }}>
<div style={{ fontSize: '0.7rem', color: '#94A3B8', marginBottom: '0.5rem', fontWeight: '600' }}>Bulk Defaults (applies to all rows)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.5rem' }}>
{orderedColumns.filter(c => c.id !== 'DELETE').map(col => (
<div key={col.id}>
<label style={{ fontSize: '0.6rem', color: '#64748B', display: 'block', marginBottom: '0.15rem' }}>
{col.id}
</label>
<input
style={INPUT}
value={bulkDefaults[col.id] || ''}
onChange={e => setBulkDefault(col.id, e.target.value)}
placeholder={`Default for all rows`}
/>
</div>
))}
</div>
</div>
)}
{/* Preview table */}
{devices.length > 0 && orderedColumns.length > 0 && (
<div style={{ border: '1px solid #334155', borderRadius: '0.5rem', overflow: 'auto', maxHeight: '300px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.7rem' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: '#0F172A', zIndex: 1 }}>
<th style={{ padding: '0.4rem', borderBottom: '1px solid #334155', color: '#64748B', textAlign: 'center', width: '30px' }}>#</th>
{orderedColumns.map(col => (
<th key={col.id} style={{ padding: '0.4rem 0.5rem', borderBottom: '1px solid #334155', color: '#94A3B8', textAlign: 'left', whiteSpace: 'nowrap' }}>
{col.id}
</th>
))}
<th style={{ padding: '0.4rem', borderBottom: '1px solid #334155', width: '30px' }}></th>
</tr>
</thead>
<tbody>
{devices.map((_, rowIdx) => (
<tr key={rowIdx} style={{ borderBottom: '1px solid rgba(51, 65, 85, 0.5)' }}>
<td style={{ padding: '0.3rem', textAlign: 'center', color: '#475569', fontSize: '0.6rem' }}>{rowIdx + 1}</td>
{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 (
<td
key={col.id}
style={{
padding: '0.2rem 0.4rem',
position: 'relative',
background: hasWarning ? 'rgba(239, 68, 68, 0.08)' : 'transparent',
cursor: 'pointer',
minWidth: '100px',
}}
onClick={() => !isEditing && startEdit(rowIdx, col.id)}
>
{isEditing ? (
<input
style={{ ...INPUT, padding: '0.2rem 0.4rem', fontSize: '0.7rem' }}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
autoFocus
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
{hasOverride && <span style={{ color: '#F59E0B', fontSize: '0.5rem' }}></span>}
<span style={{ color: value ? '#E2E8F0' : '#475569' }}>{value || '—'}</span>
{hasOverride && (
<button
onClick={e => { e.stopPropagation(); clearOverride(rowIdx, col.id); }}
style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer', fontSize: '0.55rem', padding: '0 0.2rem' }}
title="Revert to bulk default"
></button>
)}
</div>
)}
</td>
);
})}
<td style={{ padding: '0.2rem', textAlign: 'center' }}>
<button onClick={() => removeRow(rowIdx)} style={{ background: 'none', border: 'none', color: '#64748B', cursor: 'pointer' }}>
<Trash2 style={{ width: '11px', height: '11px' }} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add row button */}
{devices.length > 0 && (
<button style={{ ...BTN_SECONDARY, marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.65rem' }} onClick={addRow}>
<Plus style={{ width: '11px', height: '11px' }} /> Add Row
</button>
)}
</div>
{/* Footer */}
<div style={FOOTER}>
<div style={{ fontSize: '0.7rem', color: '#64748B' }}>
{missingCount > 0 && (
<span style={{ color: '#F59E0B' }}> {missingCount} missing required field{missingCount > 1 ? 's' : ''}</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button style={BTN_SECONDARY} onClick={onClose}>Cancel</button>
<button
style={{ ...BTN_SUCCESS, display: 'flex', alignItems: 'center', gap: '0.3rem' }}
onClick={handleDownload}
disabled={devices.length === 0 || orderedColumns.length === 0}
>
<Download style={{ width: '13px', height: '13px' }} />
Download Loader Sheet
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight } from 'lucide-react';
import { ListTodo, RefreshCw, CheckSquare, Square, Loader, AlertCircle, X, Plus, CheckCircle, ChevronDown, ChevronRight, FileSpreadsheet } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import ConsolidationModal from '../ConsolidationModal';
import LoaderModal from '../LoaderModal';
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
import { groupQueueItems } from '../../utils/queueGrouping';
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
@@ -300,6 +301,7 @@ export default function IvantiTodoQueuePage() {
// Single-item Jira creation modal state (Requirement 2.4)
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
const [showLoaderModal, setShowLoaderModal] = useState(false);
const [singleJiraItem, setSingleJiraItem] = useState(null);
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
const [singleJiraError, setSingleJiraError] = useState(null);
@@ -908,6 +910,20 @@ export default function IvantiTodoQueuePage() {
<Plus style={{ width: '14px', height: '14px' }} />
Create Jira Ticket
</button>
{(() => {
const selectedItems = queueItems.filter(i => selectedIds.has(i.id));
const hasCardGranite = selectedItems.some(i => ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type));
return hasCardGranite ? (
<button
onClick={() => setShowLoaderModal(true)}
style={STYLES.btnSuccess}
title="Generate Granite Team_Device Loader Sheet from selected items"
>
<FileSpreadsheet style={{ width: '14px', height: '14px' }} />
Generate Loader Sheet
</button>
) : null;
})()}
<button
onClick={cancelSelection}
style={STYLES.btnCancel}
@@ -1014,6 +1030,13 @@ export default function IvantiTodoQueuePage() {
onSuccess={handleConsolidationSuccess}
/>
)}
{/* Granite Loader Sheet Modal */}
<LoaderModal
isOpen={showLoaderModal}
onClose={() => setShowLoaderModal(false)}
initialDevices={showLoaderModal ? queueItems.filter(i => selectedIds.has(i.id) && ['CARD', 'GRANITE', 'DECOM'].includes(i.workflow_type)).map(i => ({ ip_address: i.ip_address || '', hostname: i.hostname || '' })) : null}
/>
</div>
);
}

View File

@@ -0,0 +1,77 @@
/**
* Granite Team_Device Loader column configuration.
* Defines all 41 columns in canonical order, their groupings,
* and which are required for each operation type.
*/
export const LOADER_COLUMNS = [
{ id: 'DELETE', label: 'DELETE', group: 'Identification', requiredFor: ['Delete'] },
{ id: 'SET_CONFIRMED', label: 'SET_CONFIRMED', group: 'Identification', requiredFor: [] },
{ id: 'EQUIPMENT_CLASS', label: 'EQUIPMENT CLASS', group: 'Identification', requiredFor: ['Add'] },
{ id: 'EQUIP_INST_ID', label: 'EQUIP_INST_ID', group: 'Identification', requiredFor: ['Change', 'Move', 'Delete'] },
{ id: 'SITE_NAME', label: 'SITE_NAME', group: 'Identification', requiredFor: ['Add', 'Move'] },
{ id: 'EQUIP_NAME', label: 'EQUIP_NAME', group: 'Identification', requiredFor: ['Add'] },
{ id: 'EQUIP_TEMPLATE', label: 'EQUIP_TEMPLATE', group: 'Identification', requiredFor: ['Add'] },
{ id: 'EQUIP_STATUS', label: 'EQUIP_STATUS', group: 'Identification', requiredFor: ['Add'] },
{ id: 'RESPONSIBLE_TEAM', label: 'UDA#RESPONSIBLE ORGANIZATION#RESPONSIBLE TEAM', group: 'Responsible Org', requiredFor: ['Add'] },
{ id: 'IPV4_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ADDRESS', group: 'IP Addressing', requiredFor: ['Add'] },
{ id: 'MAC_ADDRESS', label: 'UDA#IP_ADDRESSING#MAC ADDRESS', group: 'IP Addressing', requiredFor: [] },
{ id: 'MGMT_IP_ASN', label: 'UDA#IP_ADDRESSING#MGMT_IP_ASN', group: 'IP Addressing', requiredFor: ['Add'] },
{ id: 'SERIALNUMBER', label: 'SERIALNUMBER', group: 'Equipment Info', requiredFor: [] },
{ id: 'EXCLUDED_DISCOVERY', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY', group: 'Discovery', requiredFor: [] },
{ id: 'EXCLUDED_REASON', label: 'UDA#AUTO DISCOVERY#EXCLUDED FROM DISCOVERY REASON', group: 'Discovery', requiredFor: [] },
{ id: 'IPV6_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV6_ADDRESS', group: 'IP Addressing', requiredFor: [] },
{ id: 'ILOM_ADDRESS', label: 'UDA#IP_ADDRESSING#IPV4_ILOM_ADDRESS', group: 'IP Addressing', requiredFor: [] },
{ id: 'APP_ID_ASSET_TAG', label: 'UDA#CHERWELL_CMDB#APP ID ASSET TAG', group: 'Cyber Metrics', requiredFor: [] },
{ id: 'DEVICE_FUNCTION', label: 'UDA#CHERWELL_CMDB#DEVICE_FUNCTION', group: 'Cyber Metrics', requiredFor: [] },
{ id: 'ENVIRONMENT', label: 'UDA#CHERWELL_CMDB#ENVIRONMENT', group: 'Cyber Metrics', requiredFor: [] },
{ id: 'SECONDARY_MGMT_IP', label: 'UDA#IP_ADDRESSING#SECONDARY_MGMT_IP_ADDRESS', group: 'IP Addressing', requiredFor: [] },
{ id: 'VIP', label: 'UDA#IP_ADDRESSING#VIP', group: 'IP Addressing', requiredFor: [] },
{ id: 'FLOATING_IP', label: 'UDA#IP_ADDRESSING#FLOATING IP ADDRESS', group: 'IP Addressing', requiredFor: [] },
{ id: 'SCAN_IP_1', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 1', group: 'IP Addressing', requiredFor: [] },
{ id: 'SCAN_IP_2', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 2', group: 'IP Addressing', requiredFor: [] },
{ id: 'SCAN_IP_3', label: 'UDA#IP_ADDRESSING#SCAN IP ADDRESS 3', group: 'IP Addressing', requiredFor: [] },
{ id: 'EQUIP_MODEL', label: 'EQUIP_MODEL', group: 'Equipment Info', requiredFor: [] },
{ id: 'EQUIP_COMMENTS', label: 'EQUIP_COMMENTS', group: 'Equipment Info', requiredFor: [] },
{ id: 'EQUIP_PARTNUMBER', label: 'EQUIP_PARTNUMBER', group: 'Equipment Info', requiredFor: [] },
{ id: 'OS', label: 'UDA#CONTROLLER CONFIG#OS', group: 'Equipment Info', requiredFor: [] },
{ id: 'OS_VERSION', label: 'UDA#CONTROLLER CONFIG#OS VERSION', group: 'Equipment Info', requiredFor: [] },
{ id: 'CPU_CORES', label: 'UDA#CONTROLLER CONFIG#TOTAL CPU CORES', group: 'Equipment Info', requiredFor: [] },
{ id: 'RAM_GB', label: 'UDA#CONTROLLER CONFIG#RAM IN GB', group: 'Equipment Info', requiredFor: [] },
{ id: 'STORAGE_GB', label: 'UDA#CONTROLLER CONFIG#STORAGE IN GB', group: 'Equipment Info', requiredFor: [] },
{ id: 'ARCHER_ID', label: 'UDA#WIFI EQUIP INFO#ARCHER ID', group: 'Other', requiredFor: [] },
{ id: 'INSTALL_LOCATION', label: 'UDA#WIFI EQUIP INFO#INSTALL LOCATION', group: 'Other', requiredFor: [] },
{ id: 'SYSNAME', label: 'UDA#EQUIPMENT_INFO#SYSNAME', group: 'Other', requiredFor: [] },
{ id: 'LATITUDE', label: 'UDA#WIFI EQUIP INFO#LATITUDE', group: 'Other', requiredFor: [] },
{ id: 'LONGITUDE', label: 'UDA#WIFI EQUIP INFO#LONGITUDE', group: 'Other', requiredFor: [] },
{ id: 'OSTYPE', label: 'UDA#EQUIP MIGRATION#OSTYPE', group: 'Other', requiredFor: [] },
{ id: 'OSVERSION', label: 'UDA#EQUIP MIGRATION#OSVERSION', group: 'Other', requiredFor: [] },
];
export const COLUMN_GROUPS = [
'Identification',
'Responsible Org',
'IP Addressing',
'Discovery',
'Cyber Metrics',
'Equipment Info',
'Other',
];
export const OPERATION_TYPES = ['Change', 'Add', 'Delete', 'Move'];
/**
* Returns column IDs required for the given operation type.
*/
export function getRequiredColumns(operationType) {
return LOADER_COLUMNS
.filter(col => col.requiredFor.includes(operationType))
.map(col => col.id);
}
/**
* Returns columns belonging to a specific group.
*/
export function getColumnsByGroup(group) {
return LOADER_COLUMNS.filter(col => col.group === group);
}

View File

@@ -0,0 +1,82 @@
/**
* Granite Team_Device Loader xlsx generation.
* Produces a properly formatted xlsx file for upload to SNIP XperLoad.
*/
import * as XLSX from 'xlsx';
import { LOADER_COLUMNS } from './graniteLoaderConfig';
/**
* Generate a Granite Loader Sheet xlsx file.
*
* @param {Object} config
* @param {string} config.operationType - 'Change' | 'Add' | 'Delete' | 'Move'
* @param {Array<string>} config.columnIds - selected column IDs (in any order; output uses canonical order)
* @param {Array<Object>} config.rows - device rows, each keyed by column ID with string values
* @returns {Blob} xlsx file as a Blob for browser download
*/
export function generateLoaderXlsx(config) {
const { operationType, columnIds, rows } = config;
// Filter LOADER_COLUMNS to only selected columns, preserving canonical order
const selectedColumns = LOADER_COLUMNS.filter(col => columnIds.includes(col.id));
// Build header row from canonical labels
const headers = selectedColumns.map(col => col.label);
// Build data rows
const dataRows = rows.map(row => {
return selectedColumns.map(col => {
// DELETE column auto-fill for Delete operations
if (col.id === 'DELETE' && operationType === 'Delete') {
return 'X';
}
// Get the value from the row
let value = row[col.id];
// EQUIPMENT CLASS defaults to "S" if not explicitly set
if (col.id === 'EQUIPMENT_CLASS' && (value === undefined || value === null || value === '')) {
value = 'S';
}
// Convert null/undefined to empty string (not "null" or "undefined")
if (value === null || value === undefined) {
return '';
}
return String(value);
});
});
// Combine headers + data into array-of-arrays
const aoa = [headers, ...dataRows];
// Create workbook and worksheet
const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Load_Sheet');
// Write to array buffer
const wbOut = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
// Return as Blob
return new Blob([wbOut], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
/**
* Generate a descriptive filename for the loader sheet.
*
* @param {string} operationType - 'Change' | 'Add' | 'Delete' | 'Move'
* @param {string} [teamName] - optional team name to include in filename
* @returns {string} filename like "Loader_Change_NTS-AEO-STEAM_2026-05-27.xlsx"
*/
export function generateFilename(operationType, teamName) {
const date = new Date().toISOString().slice(0, 10);
const parts = ['Loader', operationType];
if (teamName) {
parts.push(teamName.replace(/[^a-zA-Z0-9_-]/g, '-'));
}
parts.push(date);
return parts.join('_') + '.xlsx';
}