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:
2025-10-29 11:20:22 +07:00
parent 0fdc319774
commit 03b7a4b0c8
7 changed files with 1467 additions and 1 deletions

View 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';
}
};
}