diff --git a/.gitignore b/.gitignore index 611fc3b..07335d6 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ docs/data-exports/ # Python cache __pycache__/ +docs/Team_Device Loader.xlsx diff --git a/backend/routes/cardApi.js b/backend/routes/cardApi.js index 196e455..d1cab84 100644 --- a/backend/routes/cardApi.js +++ b/backend/routes/cardApi.js @@ -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; diff --git a/backend/scripts/card-connectivity-test.js b/backend/scripts/card-connectivity-test.js new file mode 100644 index 0000000..bc61989 --- /dev/null +++ b/backend/scripts/card-connectivity-test.js @@ -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); +}); diff --git a/frontend/src/components/LoaderModal.js b/frontend/src/components/LoaderModal.js new file mode 100644 index 0000000..58fbf5a --- /dev/null +++ b/frontend/src/components/LoaderModal.js @@ -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 ( +
| # | + {orderedColumns.map(col => ( ++ {col.id} + | + ))} ++ |
|---|---|---|
| {rowIdx + 1} | + {orderedColumns.map(col => { + const value = getCellValue(rowIdx, col.id); + const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colId === col.id; + const hasOverride = isOverridden(rowIdx, col.id); + const hasWarning = isCellWarning(rowIdx, col.id); + + return ( + !isEditing && startEdit(rowIdx, col.id)}
+ >
+ {isEditing ? (
+ setEditValue(e.target.value)}
+ onBlur={commitEdit}
+ onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null); }}
+ autoFocus
+ />
+ ) : (
+
+ {hasOverride && ●}
+ {value || '—'}
+ {hasOverride && (
+
+ )}
+
+ )}
+ |
+ );
+ })}
+ + + | +