Files
inventree-stock-tool/src/stocktool/web/static/js/app.js
grabowski 0fb4170549 fix: Convert relative image URLs to full InvenTree URLs in web app
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>
2025-10-29 11:33:58 +07:00

600 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
}
};
}