Fixes 404 errors when loading part images in the web interface. Problem: - InvenTree API returns relative image paths like /media/part_images/... - Browser tries to load from Flask app instead of InvenTree server - Results in 404 errors Solution: - Detect relative image URLs (starting with /) - Prepend InvenTree host URL to make them absolute - Apply to both image and thumbnail properties 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
600 lines
20 KiB
JavaScript
600 lines
20 KiB
JavaScript
/**
|
||
* InvenTree Stock Tool - Web Application
|
||
* Alpine.js + Socket.IO client application
|
||
*/
|
||
|
||
function stockApp() {
|
||
return {
|
||
// Connection
|
||
socket: null,
|
||
connected: false,
|
||
|
||
// Configuration
|
||
config: {},
|
||
modes: {},
|
||
locations: [],
|
||
|
||
// Current state
|
||
currentLocation: '',
|
||
currentMode: 'import',
|
||
locationInput: '',
|
||
scanInput: '',
|
||
|
||
// Part data
|
||
currentPart: null,
|
||
currentParameters: null,
|
||
currentStock: null,
|
||
|
||
// Pending imports
|
||
pendingParts: [],
|
||
|
||
// Activity log
|
||
logs: [],
|
||
maxLogs: 100,
|
||
|
||
/**
|
||
* Initialize application
|
||
*/
|
||
async init() {
|
||
this.log('info', '🚀 Initializing InvenTree Stock Tool...');
|
||
|
||
// Load configuration
|
||
await this.loadConfig();
|
||
|
||
// Connect WebSocket
|
||
this.connectWebSocket();
|
||
|
||
// Load locations
|
||
await this.loadLocations();
|
||
|
||
// Focus on scan input
|
||
this.$nextTick(() => {
|
||
this.$refs.scanInput?.focus();
|
||
});
|
||
|
||
this.log('success', '✅ Application ready');
|
||
},
|
||
|
||
/**
|
||
* Load configuration from server
|
||
*/
|
||
async loadConfig() {
|
||
try {
|
||
const response = await fetch('/api/config');
|
||
const data = await response.json();
|
||
this.config = data;
|
||
this.modes = data.modes || {};
|
||
this.log('success', `📡 Connected to ${data.host}`);
|
||
} catch (error) {
|
||
this.log('error', `✖ Failed to load config: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Connect to WebSocket server
|
||
*/
|
||
connectWebSocket() {
|
||
this.socket = io();
|
||
|
||
this.socket.on('connect', () => {
|
||
this.connected = true;
|
||
this.log('success', '🔌 WebSocket connected');
|
||
});
|
||
|
||
this.socket.on('disconnect', () => {
|
||
this.connected = false;
|
||
this.log('warning', '🔌 WebSocket disconnected');
|
||
});
|
||
|
||
this.socket.on('barcode_parsed', (data) => {
|
||
this.log('info', `📋 Parsed: ${data.part_code} (Qty: ${data.quantity || 'N/A'})`);
|
||
});
|
||
|
||
this.socket.on('import_complete', (data) => {
|
||
this.log('success', `✅ Import complete: ${data.part_code}`);
|
||
this.removePendingPart(data.part_code);
|
||
// Process the imported part
|
||
this.processImportedPart(data);
|
||
});
|
||
|
||
this.socket.on('import_retry', (data) => {
|
||
this.log('warning', `⚠ Import retry ${data.retry_count}: ${data.part_code}`);
|
||
this.updatePendingPart(data.part_code, data.retry_count);
|
||
});
|
||
|
||
this.socket.on('import_failed', (data) => {
|
||
this.log('error', `✖ Import failed: ${data.part_code} - ${data.error}`);
|
||
this.removePendingPart(data.part_code);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Load available locations
|
||
*/
|
||
async loadLocations() {
|
||
try {
|
||
const response = await fetch('/api/locations');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.locations = data.locations;
|
||
this.log('success', `📍 Loaded ${data.locations.length} locations`);
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Failed to load locations: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Process location scan/input
|
||
*/
|
||
processLocationScan() {
|
||
const input = this.locationInput.trim();
|
||
if (!input) return;
|
||
|
||
// Check if it's a barcode (INV-SL prefix)
|
||
if (input.toUpperCase().startsWith('INV-SL')) {
|
||
const locId = parseInt(input.substring(6));
|
||
if (!isNaN(locId)) {
|
||
this.currentLocation = locId;
|
||
this.log('success', `📍 Location set: ${this.getCurrentLocationName()}`);
|
||
}
|
||
} else if (!isNaN(input)) {
|
||
// Direct ID
|
||
this.currentLocation = parseInt(input);
|
||
this.log('success', `📍 Location set: ${this.getCurrentLocationName()}`);
|
||
}
|
||
|
||
this.locationInput = '';
|
||
},
|
||
|
||
/**
|
||
* Handle location dropdown change
|
||
*/
|
||
onLocationChange() {
|
||
if (this.currentLocation) {
|
||
this.log('success', `📍 Location set: ${this.getCurrentLocationName()}`);
|
||
this.$refs.scanInput?.focus();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Get current location name
|
||
*/
|
||
getCurrentLocationName() {
|
||
const loc = this.locations.find(l => l.id == this.currentLocation);
|
||
return loc ? `${loc.id} - ${loc.name}` : `Location ${this.currentLocation}`;
|
||
},
|
||
|
||
/**
|
||
* Process scanned part
|
||
*/
|
||
async processScan() {
|
||
const input = this.scanInput.trim();
|
||
if (!input) return;
|
||
|
||
this.log('info', `🔍 Processing: ${input}`);
|
||
|
||
// Parse barcode
|
||
const { part_code, quantity } = await this.parseBarcode(input);
|
||
|
||
if (!part_code) {
|
||
this.log('warning', '⚠ Could not parse part code');
|
||
this.scanInput = '';
|
||
return;
|
||
}
|
||
|
||
// Check if it's a command
|
||
if (this.handleBarcodeCommand(part_code)) {
|
||
this.scanInput = '';
|
||
return;
|
||
}
|
||
|
||
// Validate location for non-locate modes
|
||
if (this.currentMode !== 'locate' && !this.currentLocation) {
|
||
this.log('warning', '⚠ Please select a location first');
|
||
this.scanInput = '';
|
||
return;
|
||
}
|
||
|
||
// Search for part
|
||
await this.searchAndProcessPart(part_code, quantity);
|
||
|
||
this.scanInput = '';
|
||
this.$refs.scanInput?.focus();
|
||
},
|
||
|
||
/**
|
||
* Parse barcode input
|
||
*
|
||
* Supports multiple formats:
|
||
* - JSON-like: {PM:PART-CODE,QTY:10}
|
||
* - Separator-based: Fields separated by GS/RS (\x1D, \x1E)
|
||
* - ANSI MH10.8.2: [)>06P<part>...Q<qty>...
|
||
*/
|
||
async parseBarcode(raw) {
|
||
let part_code = null;
|
||
let quantity = null;
|
||
|
||
/**
|
||
* Clean part code by removing non-ASCII characters
|
||
*/
|
||
const cleanPartCode = (code) => {
|
||
// Keep only ASCII printable characters (32-126)
|
||
return code.split('').filter(char => {
|
||
const charCode = char.charCodeAt(0);
|
||
return charCode >= 32 && charCode <= 126;
|
||
}).join('').trim();
|
||
};
|
||
|
||
// Handle JSON-like format: {PM:PART-CODE,QTY:10}
|
||
if (raw.startsWith('{') && raw.includes('}')) {
|
||
const content = raw.trim().slice(1, -1);
|
||
|
||
for (const kv of content.split(',')) {
|
||
if (!kv.includes(':')) continue;
|
||
|
||
const [k, v] = kv.split(':', 2);
|
||
const key = k.trim().toUpperCase();
|
||
const val = v.trim();
|
||
|
||
if (key === 'PM') {
|
||
part_code = cleanPartCode(val);
|
||
} else if (key === 'QTY') {
|
||
const parsed = parseInt(val);
|
||
if (!isNaN(parsed)) quantity = parsed;
|
||
}
|
||
}
|
||
return { part_code, quantity };
|
||
}
|
||
|
||
// Handle ANSI MH10.8.2 format: [)>06P<part>...
|
||
if (raw.startsWith('[)>06')) {
|
||
// Extract part number - it's after [)>06P and before the next field marker
|
||
if (raw.length > 6 && raw[5] === 'P') {
|
||
const afterP = raw.substring(6);
|
||
|
||
// Find the next explicit field marker (1P or 30P)
|
||
const markers = ['1P', '30P'];
|
||
let endIdx = afterP.length;
|
||
|
||
for (const marker of markers) {
|
||
const idx = afterP.indexOf(marker);
|
||
if (idx !== -1 && idx < endIdx) {
|
||
endIdx = idx;
|
||
}
|
||
}
|
||
|
||
part_code = cleanPartCode(afterP.substring(0, endIdx));
|
||
}
|
||
|
||
// Extract quantity after Q
|
||
const qMatch = raw.match(/Q(\d+)/);
|
||
if (qMatch) {
|
||
quantity = parseInt(qMatch[1]);
|
||
}
|
||
|
||
return { part_code, quantity };
|
||
}
|
||
|
||
// Handle separator-based format (GS/RS separators: \x1D or \x1E)
|
||
const sepRegex = /[\x1D\x1E]/g;
|
||
const fields = raw.split(sepRegex);
|
||
|
||
for (const field of fields) {
|
||
if (!field) continue;
|
||
|
||
// Part code patterns: 30P, 1P
|
||
if (field.startsWith('30P')) {
|
||
part_code = cleanPartCode(field.substring(3));
|
||
} else if (field.toLowerCase().startsWith('1p')) {
|
||
part_code = cleanPartCode(field.substring(2));
|
||
}
|
||
// Quantity pattern: Q followed by digits
|
||
else if (field.toLowerCase().startsWith('q') && /^\d+$/.test(field.substring(1))) {
|
||
quantity = parseInt(field.substring(1));
|
||
}
|
||
}
|
||
|
||
return { part_code, quantity };
|
||
},
|
||
|
||
/**
|
||
* Handle barcode commands
|
||
*/
|
||
handleBarcodeCommand(code) {
|
||
const upper = code.toUpperCase();
|
||
|
||
// Mode switching
|
||
const modeMap = {
|
||
'MODE:ADD': 'import', 'MODE:IMPORT': 'import', 'ADD_STOCK': 'import', 'IMPORT': 'import',
|
||
'MODE:UPDATE': 'update', 'UPDATE_STOCK': 'update', 'UPDATE': 'update',
|
||
'MODE:CHECK': 'get', 'MODE:GET': 'get', 'CHECK_STOCK': 'get', 'CHECK': 'get',
|
||
'MODE:LOCATE': 'locate', 'LOCATE_PART': 'locate', 'LOCATE': 'locate', 'FIND_PART': 'locate'
|
||
};
|
||
|
||
if (modeMap[upper]) {
|
||
this.currentMode = modeMap[upper];
|
||
this.log('info', `🔄 Switched to ${this.modes[this.currentMode]} mode`);
|
||
return true;
|
||
}
|
||
|
||
// Location change
|
||
if (['CHANGE_LOCATION', 'NEW_LOCATION', 'SET_LOCATION', 'LOCATION'].includes(upper)) {
|
||
this.currentLocation = '';
|
||
this.log('info', '📍 Location change mode - scan new location');
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
},
|
||
|
||
/**
|
||
* Search and process part
|
||
*/
|
||
async searchAndProcessPart(partCode, quantity) {
|
||
try {
|
||
const response = await fetch('/api/part/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ part_code: partCode })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success && data.found) {
|
||
// Part found
|
||
this.currentPart = data.part_info;
|
||
this.currentParameters = data.parameters;
|
||
|
||
// Fix image URLs - convert relative paths to full InvenTree URLs
|
||
if (this.currentPart.image && this.currentPart.image.startsWith('/')) {
|
||
this.currentPart.image = this.config.host + this.currentPart.image;
|
||
}
|
||
if (this.currentPart.thumbnail && this.currentPart.thumbnail.startsWith('/')) {
|
||
this.currentPart.thumbnail = this.config.host + this.currentPart.thumbnail;
|
||
}
|
||
|
||
this.log('success', `✅ Found part: ${partCode}`);
|
||
|
||
// Execute operation
|
||
await this.executeOperation(data.part_id, partCode, quantity);
|
||
} else {
|
||
// Part not found - queue for import
|
||
this.log('warning', `⏳ Part ${partCode} not found - queuing for import`);
|
||
await this.queueImport(partCode, quantity);
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Error: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Execute stock operation based on mode
|
||
*/
|
||
async executeOperation(partId, partCode, quantity) {
|
||
switch (this.currentMode) {
|
||
case 'import':
|
||
await this.addStock(partId, quantity);
|
||
break;
|
||
case 'update':
|
||
await this.updateStock(partId);
|
||
break;
|
||
case 'get':
|
||
await this.checkStock(partId);
|
||
break;
|
||
case 'locate':
|
||
await this.locatePart(partId);
|
||
break;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Add stock
|
||
*/
|
||
async addStock(partId, quantity) {
|
||
if (!quantity) {
|
||
this.log('warning', '⚠ No quantity found in scan');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/stock/add', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
part_id: partId,
|
||
location_id: this.currentLocation,
|
||
quantity: quantity
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (data.action === 'updated') {
|
||
this.log('success', `✔ Added ${quantity}× (${data.previous_quantity} → ${data.new_quantity})`);
|
||
} else {
|
||
this.log('success', `✔ Created new stock item with ${quantity}×`);
|
||
}
|
||
this.currentStock = data.new_quantity || data.quantity;
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Error adding stock: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Update stock
|
||
*/
|
||
async updateStock(partId) {
|
||
const newQty = prompt('Enter new stock quantity:', this.currentStock || 0);
|
||
if (newQty === null) return;
|
||
|
||
const quantity = parseInt(newQty);
|
||
if (isNaN(quantity)) {
|
||
this.log('error', '✖ Invalid quantity');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/stock/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
part_id: partId,
|
||
location_id: this.currentLocation,
|
||
quantity: quantity
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.log('success', `✔ Updated stock: ${data.previous_quantity || 0} → ${quantity}`);
|
||
this.currentStock = quantity;
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Error updating stock: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Check stock
|
||
*/
|
||
async checkStock(partId) {
|
||
try {
|
||
const response = await fetch('/api/stock/check', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
part_id: partId,
|
||
location_id: this.currentLocation
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.currentStock = data.quantity;
|
||
this.log('info', `ℹ Current stock: ${data.quantity}`);
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Error checking stock: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Locate part
|
||
*/
|
||
async locatePart(partId) {
|
||
try {
|
||
const response = await fetch('/api/part/locate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ part_id: partId })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.log('info', `📍 Found in ${data.locations.length} location(s)`);
|
||
data.locations.forEach(loc => {
|
||
this.log('info', ` 📌 ${loc.location_path || loc.location_name} - Qty: ${loc.quantity}`);
|
||
});
|
||
this.log('info', `📊 Total stock: ${data.total_stock}`);
|
||
this.currentStock = data.total_stock;
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Error locating part: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Queue part for import
|
||
*/
|
||
async queueImport(partCode, quantity) {
|
||
try {
|
||
const response = await fetch('/api/part/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
part_code: partCode,
|
||
quantity: quantity,
|
||
location_id: this.currentLocation,
|
||
mode: this.currentMode
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.pendingParts.push(data.pending_part);
|
||
this.log('info', `📋 Added ${partCode} to import queue - continue scanning`);
|
||
}
|
||
} catch (error) {
|
||
this.log('error', `✖ Error queuing import: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Process imported part
|
||
*/
|
||
async processImportedPart(data) {
|
||
if (data.part_id) {
|
||
await this.searchAndProcessPart(data.part_code, data.pending_part.quantity);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Update pending part retry count
|
||
*/
|
||
updatePendingPart(partCode, retryCount) {
|
||
const part = this.pendingParts.find(p => p.part_code === partCode);
|
||
if (part) {
|
||
part.retry_count = retryCount;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Remove pending part
|
||
*/
|
||
removePendingPart(partCode) {
|
||
this.pendingParts = this.pendingParts.filter(p => p.part_code !== partCode);
|
||
},
|
||
|
||
/**
|
||
* Add log message
|
||
*/
|
||
log(type, message) {
|
||
const time = new Date().toLocaleTimeString();
|
||
this.logs.push({ type, message, time });
|
||
|
||
// Keep only last N logs
|
||
if (this.logs.length > this.maxLogs) {
|
||
this.logs.shift();
|
||
}
|
||
|
||
// Auto-scroll log
|
||
this.$nextTick(() => {
|
||
const container = this.$refs.logContainer;
|
||
if (container) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Get log CSS class
|
||
*/
|
||
getLogClass(type) {
|
||
const classes = {
|
||
'info': 'text-blue-400',
|
||
'success': 'text-green-400',
|
||
'warning': 'text-yellow-400',
|
||
'error': 'text-red-400'
|
||
};
|
||
return classes[type] || 'text-gray-400';
|
||
}
|
||
};
|
||
}
|