Add create new part functionality to library browser
- Add UI to create parts from missing IPNs in spreadsheet - "+ Add New Part" button switches to create part view - Auto-populate fields from spreadsheet (description, class, manufacturer, MPN) - Normalize class field (uppercase, replace special chars with underscores) - Add optional datasheet URL field - Exclude section header rows (no manufacturer/MPN) - Cancel button returns to browser view - Align logo banner and h1 header to left 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
138
app.py
138
app.py
@@ -1089,6 +1089,144 @@ def handle_search_parts(data):
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('get_missing_ipns')
|
||||
def handle_get_missing_ipns():
|
||||
try:
|
||||
# Load config to get spreadsheet path
|
||||
config_path = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
spreadsheet_path = config.get('parts_spreadsheet_path', '')
|
||||
if not spreadsheet_path or not os.path.exists(spreadsheet_path):
|
||||
emit('library_error', {'error': 'Parts spreadsheet not found'})
|
||||
return
|
||||
|
||||
# Get all IPNs from database
|
||||
conn = get_db_connection()
|
||||
if not conn:
|
||||
emit('library_error', {'error': 'Could not connect to database'})
|
||||
return
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT ipn FROM parts")
|
||||
db_ipns = set(row.ipn for row in cursor.fetchall() if row.ipn)
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# Read spreadsheet
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(spreadsheet_path, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
|
||||
# Find header row and column indices
|
||||
header_row = None
|
||||
for row in ws.iter_rows(min_row=1, max_row=10, values_only=True):
|
||||
if row and 'GLE P/N' in row:
|
||||
header_row = row
|
||||
break
|
||||
|
||||
if not header_row:
|
||||
emit('library_error', {'error': 'Could not find header row in spreadsheet'})
|
||||
return
|
||||
|
||||
ipn_col = header_row.index('GLE P/N')
|
||||
desc_col = header_row.index('Description') if 'Description' in header_row else None
|
||||
class_col = header_row.index('Class') if 'Class' in header_row else None
|
||||
mfg_col = header_row.index('Mfg.1') if 'Mfg.1' in header_row else None
|
||||
mpn_col = header_row.index('Mfg.1 P/N') if 'Mfg.1 P/N' in header_row else None
|
||||
|
||||
# Collect missing IPNs
|
||||
missing_parts = []
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
if not row or not row[ipn_col]:
|
||||
continue
|
||||
|
||||
ipn = str(row[ipn_col]).strip()
|
||||
if not ipn or ipn in db_ipns:
|
||||
continue
|
||||
|
||||
# Get manufacturer and MPN
|
||||
manufacturer = str(row[mfg_col]).strip() if mfg_col and row[mfg_col] else ''
|
||||
mpn = str(row[mpn_col]).strip() if mpn_col and row[mpn_col] else ''
|
||||
|
||||
# Skip section headers - rows with IPN and description but no manufacturer or MPN
|
||||
if not manufacturer and not mpn:
|
||||
continue
|
||||
|
||||
# Get and normalize class field
|
||||
class_value = str(row[class_col]).strip() if class_col and row[class_col] else ''
|
||||
if class_value:
|
||||
# Replace spaces and special characters with underscore, collapse multiple underscores
|
||||
import re
|
||||
class_value = re.sub(r'[^a-zA-Z0-9]+', '_', class_value.upper())
|
||||
class_value = re.sub(r'_+', '_', class_value).strip('_')
|
||||
|
||||
part = {
|
||||
'ipn': ipn,
|
||||
'description': str(row[desc_col]).strip() if desc_col and row[desc_col] else '',
|
||||
'class': class_value,
|
||||
'manufacturer': manufacturer,
|
||||
'mpn': mpn
|
||||
}
|
||||
missing_parts.append(part)
|
||||
|
||||
wb.close()
|
||||
|
||||
# Sort by IPN
|
||||
missing_parts.sort(key=lambda x: x['ipn'])
|
||||
|
||||
emit('missing_ipns_result', {'parts': missing_parts, 'count': len(missing_parts)})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('create_part')
|
||||
def handle_create_part(data):
|
||||
try:
|
||||
ipn = data.get('ipn', '').strip()
|
||||
manufacturer = data.get('manufacturer', '').strip()
|
||||
mpn = data.get('mpn', '').strip()
|
||||
description = data.get('description', '').strip()
|
||||
class_value = data.get('class', '').strip()
|
||||
datasheet = data.get('datasheet', '').strip()
|
||||
|
||||
if not ipn:
|
||||
emit('library_error', {'error': 'IPN is required'})
|
||||
return
|
||||
|
||||
conn = get_db_connection()
|
||||
if not conn:
|
||||
emit('library_error', {'error': 'Could not connect to database'})
|
||||
return
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if part already exists
|
||||
cursor.execute("SELECT ipn FROM parts WHERE ipn = ?", (ipn,))
|
||||
if cursor.fetchone():
|
||||
emit('library_error', {'error': f'Part {ipn} already exists in database'})
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Insert new part
|
||||
cursor.execute("""
|
||||
INSERT INTO parts (ipn, manufacturer, mpn, description, class, datasheet)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (ipn, manufacturer, mpn, description, class_value, datasheet))
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
emit('part_created', {'message': f'Successfully created part {ipn}'})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOM Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
color: #333;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.arg-container {
|
||||
background: white;
|
||||
@@ -265,7 +266,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="{{ url_for('static', filename='logo_banner.png') }}" alt="Banner" style="width: 100%; max-width: 800px; display: block; margin: 0 auto 20px auto;">
|
||||
<img src="{{ url_for('static', filename='logo_banner.png') }}" alt="Banner" style="width: 100%; max-width: 800px; display: block; margin: 0 0 20px 0;">
|
||||
|
||||
<div id="status" class="status disconnected">Disconnected</div>
|
||||
|
||||
@@ -375,11 +376,14 @@
|
||||
<div class="container">
|
||||
<h2>Parts Library Browser</h2>
|
||||
|
||||
<!-- Browser View -->
|
||||
<div id="libraryBrowserView">
|
||||
<div style="margin: 20px 0;">
|
||||
<input type="text" id="librarySearchInput" placeholder="Search by IPN, MPN, Manufacturer, or Description..."
|
||||
style="width: 70%; padding: 10px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
|
||||
<button id="librarySearchBtn" class="btn" style="margin-left: 10px;">Search</button>
|
||||
<button id="libraryClearBtn" class="btn secondary" style="margin-left: 5px;">Clear</button>
|
||||
<button id="showCreatePartBtn" class="btn" style="margin-left: 10px; background-color: #28a745;">+ Add New Part</button>
|
||||
</div>
|
||||
|
||||
<div id="libraryMessage" class="message"></div>
|
||||
@@ -388,6 +392,48 @@
|
||||
<p style="color: #6c757d;">Enter a search query or click Search to browse all parts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Part View -->
|
||||
<div id="libraryCreateView" style="display: none;">
|
||||
<div style="margin: 20px 0; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||
<h3>Create New Part</h3>
|
||||
<div id="createPartFormLoading" style="padding: 20px; text-align: center;">
|
||||
<p>Loading missing IPNs from spreadsheet...</p>
|
||||
</div>
|
||||
<div id="createPartForm" style="display: none;">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Select IPN:</label>
|
||||
<select id="ipnDropdown" style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
|
||||
<option value="">-- Select an IPN --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Description:</label>
|
||||
<input type="text" id="descriptionInput" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;">
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Class:</label>
|
||||
<input type="text" id="classInput" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;">
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Manufacturer:</label>
|
||||
<input type="text" id="manufacturerInput" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;">
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 5px;">MPN:</label>
|
||||
<input type="text" id="mpnInput" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;">
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Datasheet URL (optional):</label>
|
||||
<input type="text" id="datasheetInput" placeholder="https://..." style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
|
||||
</div>
|
||||
<button id="createPartBtn" class="btn" disabled>Create Part</button>
|
||||
<button id="cancelCreatePartBtn" class="btn secondary" style="margin-left: 10px;">Cancel</button>
|
||||
</div>
|
||||
<div id="createPartMessage" class="message"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End libraryTab -->
|
||||
|
||||
{% if not library_only_mode %}
|
||||
@@ -926,6 +972,152 @@
|
||||
showLibraryMessage('Error: ' + data.error, 'error');
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Create New Part
|
||||
// ========================================
|
||||
const libraryBrowserView = document.getElementById('libraryBrowserView');
|
||||
const libraryCreateView = document.getElementById('libraryCreateView');
|
||||
const showCreatePartBtn = document.getElementById('showCreatePartBtn');
|
||||
const cancelCreatePartBtn = document.getElementById('cancelCreatePartBtn');
|
||||
const createPartFormLoading = document.getElementById('createPartFormLoading');
|
||||
const createPartForm = document.getElementById('createPartForm');
|
||||
const createPartMessage = document.getElementById('createPartMessage');
|
||||
const ipnDropdown = document.getElementById('ipnDropdown');
|
||||
const descriptionInput = document.getElementById('descriptionInput');
|
||||
const classInput = document.getElementById('classInput');
|
||||
const manufacturerInput = document.getElementById('manufacturerInput');
|
||||
const mpnInput = document.getElementById('mpnInput');
|
||||
const datasheetInput = document.getElementById('datasheetInput');
|
||||
const createPartBtn = document.getElementById('createPartBtn');
|
||||
|
||||
let missingPartsData = [];
|
||||
|
||||
function showCreatePartMessage(text, type) {
|
||||
createPartMessage.textContent = text;
|
||||
createPartMessage.className = 'message ' + type;
|
||||
createPartMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
// Show create part view
|
||||
showCreatePartBtn.addEventListener('click', () => {
|
||||
libraryBrowserView.style.display = 'none';
|
||||
libraryCreateView.style.display = 'block';
|
||||
createPartFormLoading.style.display = 'block';
|
||||
createPartForm.style.display = 'none';
|
||||
createPartMessage.style.display = 'none';
|
||||
socket.emit('get_missing_ipns');
|
||||
});
|
||||
|
||||
// Cancel and return to browser
|
||||
cancelCreatePartBtn.addEventListener('click', () => {
|
||||
libraryCreateView.style.display = 'none';
|
||||
libraryBrowserView.style.display = 'block';
|
||||
|
||||
// Clear form
|
||||
ipnDropdown.innerHTML = '<option value="">-- Select an IPN --</option>';
|
||||
descriptionInput.value = '';
|
||||
classInput.value = '';
|
||||
manufacturerInput.value = '';
|
||||
mpnInput.value = '';
|
||||
datasheetInput.value = '';
|
||||
createPartBtn.disabled = true;
|
||||
missingPartsData = [];
|
||||
});
|
||||
|
||||
socket.on('missing_ipns_result', (data) => {
|
||||
createPartFormLoading.style.display = 'none';
|
||||
|
||||
if (data.count === 0) {
|
||||
showCreatePartMessage('No missing IPNs found. All parts from spreadsheet are in database!', 'success');
|
||||
createPartForm.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
missingPartsData = data.parts;
|
||||
|
||||
// Populate dropdown
|
||||
ipnDropdown.innerHTML = '<option value="">-- Select an IPN --</option>';
|
||||
for (const part of data.parts) {
|
||||
const option = document.createElement('option');
|
||||
option.value = part.ipn;
|
||||
option.textContent = `${part.ipn} - ${part.description}`;
|
||||
ipnDropdown.appendChild(option);
|
||||
}
|
||||
|
||||
createPartForm.style.display = 'block';
|
||||
showCreatePartMessage(`Found ${data.count} missing IPN(s). Select one to create in database.`, 'success');
|
||||
});
|
||||
|
||||
ipnDropdown.addEventListener('change', () => {
|
||||
const selectedIpn = ipnDropdown.value;
|
||||
|
||||
if (!selectedIpn) {
|
||||
descriptionInput.value = '';
|
||||
classInput.value = '';
|
||||
manufacturerInput.value = '';
|
||||
mpnInput.value = '';
|
||||
createPartBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const part = missingPartsData.find(p => p.ipn === selectedIpn);
|
||||
if (part) {
|
||||
descriptionInput.value = part.description;
|
||||
classInput.value = part.class;
|
||||
manufacturerInput.value = part.manufacturer;
|
||||
mpnInput.value = part.mpn;
|
||||
createPartBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
createPartBtn.addEventListener('click', () => {
|
||||
const selectedIpn = ipnDropdown.value;
|
||||
if (!selectedIpn) return;
|
||||
|
||||
const part = missingPartsData.find(p => p.ipn === selectedIpn);
|
||||
if (!part) return;
|
||||
|
||||
createPartBtn.disabled = true;
|
||||
showCreatePartMessage('Creating part in database...', 'info');
|
||||
socket.emit('create_part', {
|
||||
ipn: part.ipn,
|
||||
description: part.description,
|
||||
class: part.class,
|
||||
manufacturer: part.manufacturer,
|
||||
mpn: part.mpn,
|
||||
datasheet: datasheetInput.value.trim()
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('part_created', (data) => {
|
||||
showCreatePartMessage(data.message, 'success');
|
||||
createPartBtn.disabled = false;
|
||||
|
||||
// Remove created part from dropdown
|
||||
const selectedIpn = ipnDropdown.value;
|
||||
missingPartsData = missingPartsData.filter(p => p.ipn !== selectedIpn);
|
||||
|
||||
ipnDropdown.innerHTML = '<option value="">-- Select an IPN --</option>';
|
||||
for (const part of missingPartsData) {
|
||||
const option = document.createElement('option');
|
||||
option.value = part.ipn;
|
||||
option.textContent = `${part.ipn} - ${part.description}`;
|
||||
ipnDropdown.appendChild(option);
|
||||
}
|
||||
|
||||
descriptionInput.value = '';
|
||||
classInput.value = '';
|
||||
manufacturerInput.value = '';
|
||||
mpnInput.value = '';
|
||||
datasheetInput.value = '';
|
||||
createPartBtn.disabled = true;
|
||||
|
||||
if (missingPartsData.length === 0) {
|
||||
createPartForm.style.display = 'none';
|
||||
showCreatePartMessage('All missing parts have been created!', 'success');
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Variant Manager
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user