feat: Add web application interface
Implements a modern web-based interface alongside the existing desktop app. Features: - Flask backend with REST API and WebSocket support - Responsive web UI with TailwindCSS and Alpine.js - Real-time updates via Socket.IO - All desktop features available in browser - Multi-user support - Mobile-friendly responsive design - Same async import queue functionality Technology Stack: - Backend: Flask + Flask-SocketIO + Flask-CORS - Frontend: HTML5 + TailwindCSS + Alpine.js - Real-time: WebSocket (Socket.IO) - Icons: Font Awesome 6 New Files: - src/stocktool/web/app.py - Flask application server - src/stocktool/web/templates/index.html - Main web interface - src/stocktool/web/static/js/app.js - Alpine.js application logic - WEB_APP.md - Complete web app documentation API Endpoints: - GET /api/config - Application configuration - GET /api/locations - List locations - POST /api/part/search - Search for part - POST /api/part/import - Queue part import - POST /api/stock/add - Add stock - POST /api/stock/update - Update stock - POST /api/stock/check - Check stock level - POST /api/part/locate - Locate part - GET /api/pending - Get pending imports WebSocket Events: - import_complete - Part import finished - import_retry - Import failed, retrying - import_failed - Import failed completely - barcode_parsed - Barcode successfully parsed Benefits: - Access from any device with a browser - No desktop installation required - Better mobile experience - Multiple users can work simultaneously - Easier deployment and updates - Network-accessible within local network Usage: uv run stock-tool-web # Open browser to http://localhost:5000 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
515
src/stocktool/web/static/js/app.js
Normal file
515
src/stocktool/web/static/js/app.js
Normal file
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async parseBarcode(raw) {
|
||||
// Simple parsing - can be enhanced
|
||||
let part_code = raw;
|
||||
let quantity = null;
|
||||
|
||||
// Check for JSON-like format
|
||||
if (raw.startsWith('{') && raw.includes('}')) {
|
||||
const match = raw.match(/pm:([^,}]+)/i);
|
||||
if (match) part_code = match[1];
|
||||
|
||||
const qtyMatch = raw.match(/qty:(\d+)/i);
|
||||
if (qtyMatch) quantity = parseInt(qtyMatch[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;
|
||||
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';
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user