From bcb2c70e9305412b9277df11398dac027dbcdc64 Mon Sep 17 00:00:00 2001 From: brentperteet Date: Mon, 23 Feb 2026 09:30:19 -0600 Subject: [PATCH] Add create new part functionality to library browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.py | 138 ++++++++++++++++++++++++++++ templates/index.html | 212 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 340 insertions(+), 10 deletions(-) diff --git a/app.py b/app.py index 4e8b14d..74c475c 100644 --- a/app.py +++ b/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 # --------------------------------------------------------------------------- diff --git a/templates/index.html b/templates/index.html index ed9f529..26d3fdd 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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 @@ - Banner + Banner
Disconnected
@@ -375,17 +376,62 @@

Parts Library Browser

-
- - - + +
+
+ + + + +
+ +
+ +
+

Enter a search query or click Search to browse all parts

+
-
- -
-

Enter a search query or click Search to browse all parts

+ +
@@ -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 = ''; + 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 = ''; + 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 = ''; + 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 // ========================================