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