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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -71,3 +71,4 @@ docs/data-exports/
|
|||||||
|
|
||||||
# Python cache
|
# Python cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
docs/Team_Device Loader.xlsx
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ function handleCardError(err, res) {
|
|||||||
function createCardApiRouter() {
|
function createCardApiRouter() {
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/status', requireAuth(), (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
||||||
@@ -60,7 +67,14 @@ function createCardApiRouter() {
|
|||||||
res.json({ configured: true });
|
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) => {
|
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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) => {
|
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
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;
|
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;
|
module.exports = createCardApiRouter;
|
||||||
|
|||||||
68
backend/scripts/card-connectivity-test.js
Normal file
68
backend/scripts/card-connectivity-test.js
Normal 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);
|
||||||
|
});
|
||||||
574
frontend/src/components/LoaderModal.js
Normal file
574
frontend/src/components/LoaderModal.js
Normal 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.240.78.111 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import ConsolidationModal from '../ConsolidationModal';
|
import ConsolidationModal from '../ConsolidationModal';
|
||||||
|
import LoaderModal from '../LoaderModal';
|
||||||
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
import { generateConsolidatedSummary, generateConsolidatedDescription, extractFirstCve, extractCommonVendor } from '../../utils/jiraConsolidation';
|
||||||
import { groupQueueItems } from '../../utils/queueGrouping';
|
import { groupQueueItems } from '../../utils/queueGrouping';
|
||||||
import { VENDOR_PROJECT_KEYS, VENDOR_ISSUE_TYPES, STEAM_ISSUE_TYPES, isVendorProject, getIssueTypesForProject } from './JiraPage';
|
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)
|
// Single-item Jira creation modal state (Requirement 2.4)
|
||||||
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
const [showSingleJiraModal, setShowSingleJiraModal] = useState(false);
|
||||||
|
const [showLoaderModal, setShowLoaderModal] = useState(false);
|
||||||
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
const [singleJiraItem, setSingleJiraItem] = useState(null);
|
||||||
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
|
const [singleJiraForm, setSingleJiraForm] = useState({ cve_id: '', vendor: '', summary: '', description: '', source_context: 'ivanti_queue', project_key: '', issue_type: '' });
|
||||||
const [singleJiraError, setSingleJiraError] = useState(null);
|
const [singleJiraError, setSingleJiraError] = useState(null);
|
||||||
@@ -908,6 +910,20 @@ export default function IvantiTodoQueuePage() {
|
|||||||
<Plus style={{ width: '14px', height: '14px' }} />
|
<Plus style={{ width: '14px', height: '14px' }} />
|
||||||
Create Jira Ticket
|
Create Jira Ticket
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={cancelSelection}
|
onClick={cancelSelection}
|
||||||
style={STYLES.btnCancel}
|
style={STYLES.btnCancel}
|
||||||
@@ -1014,6 +1030,13 @@ export default function IvantiTodoQueuePage() {
|
|||||||
onSuccess={handleConsolidationSuccess}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
77
frontend/src/utils/graniteLoaderConfig.js
Normal file
77
frontend/src/utils/graniteLoaderConfig.js
Normal 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);
|
||||||
|
}
|
||||||
82
frontend/src/utils/graniteLoaderExport.js
Normal file
82
frontend/src/utils/graniteLoaderExport.js
Normal 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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user