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 @@
-
+
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
+
+
+
+
Create New Part
+
+
+
+
@@ -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
// ========================================