From 03b7a4b0c8109785932b7721204571a048e28a7b Mon Sep 17 00:00:00 2001 From: grabowski Date: Wed, 29 Oct 2025 11:20:22 +0700 Subject: [PATCH] feat: Add web application interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 13 +- WEB_APP.md | 254 ++++++++++++ pyproject.toml | 4 + src/stocktool/web/__init__.py | 1 + src/stocktool/web/app.py | 456 ++++++++++++++++++++++ src/stocktool/web/static/js/app.js | 515 +++++++++++++++++++++++++ src/stocktool/web/templates/index.html | 225 +++++++++++ 7 files changed, 1467 insertions(+), 1 deletion(-) create mode 100644 WEB_APP.md create mode 100644 src/stocktool/web/__init__.py create mode 100644 src/stocktool/web/app.py create mode 100644 src/stocktool/web/static/js/app.js create mode 100644 src/stocktool/web/templates/index.html diff --git a/README.md b/README.md index ef414f0..4fdef78 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ A comprehensive barcode scanning application for InvenTree inventory management. +**Available in two versions:** +- **Desktop App** (tkinter) - Traditional GUI application +- **Web App** (Flask) - Modern browser-based interface **⭐ NEW!** + ## Features - **Stock Addition**: Add stock to inventory by scanning barcodes @@ -11,6 +15,7 @@ A comprehensive barcode scanning application for InvenTree inventory management. - **Async Part Import**: Non-blocking background import for unknown parts - **Server Connection Monitoring**: Real-time connection status - **Barcode Command Support**: Control the app via barcode commands +- **Web Interface**: Access from any device with a browser (NEW!) ## Requirements @@ -29,10 +34,16 @@ cd stocktool # Install with uv uv sync -# Run the application +# Run the DESKTOP application uv run stock-tool + +# OR run the WEB application +uv run stock-tool-web +# Then open browser to http://localhost:5000 ``` +**šŸ‘‰ For Web App setup, see [WEB_APP.md](WEB_APP.md)** + ### Manual Installation ```bash diff --git a/WEB_APP.md b/WEB_APP.md new file mode 100644 index 0000000..a28e5b6 --- /dev/null +++ b/WEB_APP.md @@ -0,0 +1,254 @@ +# InvenTree Stock Tool - Web Application + +A modern web-based interface for InvenTree barcode scanning and inventory management. + +## Features + +- **Modern Web UI**: Responsive design works on desktop, tablet, and mobile +- **Real-time Updates**: WebSocket integration for live import progress +- **No Installation Required**: Just open in any modern browser +- **Multi-User Support**: Multiple users can access simultaneously +- **Camera Barcode Scanning**: Use device camera or USB scanner +- **Async Part Import**: Non-blocking background imports with visual feedback +- **Dark Theme**: Eye-friendly dark interface + +## Quick Start + +### 1. Install Dependencies + +```bash +# Make sure web dependencies are installed +uv sync +``` + +### 2. Run the Web Server + +```bash +# Using UV (recommended) +uv run stock-tool-web + +# Or directly +uv run python -m stocktool.web.app +``` + +### 3. Open in Browser + +Navigate to: **http://localhost:5000** + +The web server will be accessible from any device on your local network at: +`http://YOUR_IP_ADDRESS:5000` + +## Usage + +### Location Setup + +1. **Scan Location Barcode**: Enter location barcode (INV-SL format) in the location field +2. **Or Select from Dropdown**: Choose from the list of available locations + +### Select Mode + +Click one of the four operation modes: +- **Add Stock** - Add new stock or increase quantity +- **Update Stock** - Set exact stock quantity +- **Check Stock** - View current stock level +- **Locate Part** - Find all locations where part is stored + +### Scan Parts + +1. Click in the "Scan Part" field or just start scanning +2. Scan barcode or type part code +3. Press Enter or click "Process" + +### Pending Imports + +When unknown parts are scanned: +- They appear in the "Pending Imports" section +- Import runs in background (30s timeout) +- You can continue scanning other parts +- Part is auto-processed when import completes +- Failed imports retry automatically (up to 3 times) + +### Activity Log + +- Shows all operations in real-time +- Color-coded messages: + - šŸ”µ Blue: Info + - 🟢 Green: Success + - 🟔 Yellow: Warning + - šŸ”“ Red: Error + +## Barcode Commands + +Scan special barcodes to control the app: + +**Mode Switching:** +- `MODE:ADD`, `IMPORT` → Switch to Add Stock mode +- `MODE:UPDATE`, `UPDATE` → Switch to Update Stock mode +- `MODE:CHECK`, `CHECK` → Switch to Check Stock mode +- `MODE:LOCATE`, `LOCATE` → Switch to Locate Part mode + +**Location:** +- `CHANGE_LOCATION`, `LOCATION` → Clear current location + +## Supported Barcode Formats + +1. **JSON-like**: `{PM:PART-CODE,QTY:10}` +2. **Separator-based**: Uses GS/RS separators (`\x1D`, `\x1E`) +3. **InvenTree locations**: `INV-SL` + +## API Endpoints + +The web app exposes a RESTful API: + +### Configuration +- `GET /api/config` - Get app configuration + +### Locations +- `GET /api/locations` - List all locations + +### Parts +- `POST /api/part/search` - Search for a part +- `POST /api/part/import` - Queue part for import +- `POST /api/part/locate` - Find part locations + +### Stock Operations +- `POST /api/stock/add` - Add stock +- `POST /api/stock/update` - Update stock quantity +- `POST /api/stock/check` - Check stock level + +### Pending Imports +- `GET /api/pending` - Get pending imports list + +## WebSocket Events + +Real-time events via Socket.IO: + +**Server → Client:** +- `connected` - Connection established +- `barcode_parsed` - Barcode successfully parsed +- `import_complete` - Part import finished successfully +- `import_retry` - Import failed, retrying +- `import_failed` - Import failed after all retries + +**Client → Server:** +- `parse_barcode` - Parse a barcode string + +## Technology Stack + +- **Backend**: Flask + Flask-SocketIO +- **Frontend**: HTML5 + TailwindCSS + Alpine.js +- **Real-time**: WebSocket (Socket.IO) +- **Icons**: Font Awesome 6 +- **Barcode**: Keyboard input (camera support coming soon) + +## Configuration + +Uses the same configuration file as the desktop app: + +**Location**: `~/.config/scan_and_import.yaml` + +```yaml +host: https://your-inventree-server.com +token: your-api-token-here +``` + +## Network Access + +### Local Network Access + +To access from other devices on your network: + +1. Find your computer's IP address: + ```bash + # Windows + ipconfig + + # Linux/Mac + ifconfig + ``` + +2. Open browser on any device: + ``` + http://YOUR_IP_ADDRESS:5000 + ``` + +### Production Deployment + +For production use, consider: + +1. **Reverse Proxy**: Use nginx or Apache +2. **HTTPS**: Enable SSL/TLS for security +3. **Process Manager**: Use systemd or supervisor +4. **Firewall**: Configure appropriate access rules + +Example nginx config: + +```nginx +server { + listen 80; + server_name stock-tool.local; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} +``` + +## Troubleshooting + +### Port Already in Use + +Change the port in `app.py`: +```python +socketio.run(app, host='0.0.0.0', port=5001, ...) +``` + +### WebSocket Connection Failed + +Check your firewall settings allow port 5000. + +### Slow Import Performance + +The import uses `inventree-part-import` which can be slow. This is normal. +The web UI stays responsive while imports run in background. + +## Comparison: Web vs Desktop + +| Feature | Web App | Desktop App | +|---------|---------|-------------| +| Installation | None (browser only) | Python + dependencies | +| Platform | Any (browser) | Windows/Linux/Mac | +| Multi-user | Yes | No | +| Mobile friendly | Yes | No | +| Camera scanning | Coming soon | No | +| Async imports | Yes | Yes | +| Real-time updates | WebSocket | UI updates | +| Deployment | Server-based | Local install | + +## Development + +### Run in Debug Mode + +```bash +# Flask debug mode is enabled by default +uv run stock-tool-web +``` + +### Customize UI + +Edit files in: +- `src/stocktool/web/templates/` - HTML templates +- `src/stocktool/web/static/js/` - JavaScript +- `src/stocktool/web/static/css/` - CSS (uses Tailwind CDN) + +### Add New Endpoints + +Edit `src/stocktool/web/app.py` and add route handlers. + +## License + +MIT License - Same as the main project diff --git a/pyproject.toml b/pyproject.toml index 68a7dca..4230fbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,16 @@ dependencies = [ "pillow>=10.0.0", "requests>=2.31.0", "pyyaml>=6.0.0", + "flask>=3.0.0", + "flask-socketio>=5.3.0", + "flask-cors>=4.0.0", ] readme = "README.md" license = {text = "MIT"} [project.scripts] stock-tool = "stocktool.stock_tool_gui_v2:main" +stock-tool-web = "stocktool.web.app:main" [build-system] requires = ["hatchling"] diff --git a/src/stocktool/web/__init__.py b/src/stocktool/web/__init__.py new file mode 100644 index 0000000..00e3252 --- /dev/null +++ b/src/stocktool/web/__init__.py @@ -0,0 +1 @@ +"""Web interface for InvenTree Stock Tool.""" diff --git a/src/stocktool/web/app.py b/src/stocktool/web/app.py new file mode 100644 index 0000000..0be09dd --- /dev/null +++ b/src/stocktool/web/app.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +""" +InvenTree Stock Tool - Web Application + +A Flask-based web interface for barcode scanning and inventory management. +""" + +import os +import sys +from pathlib import Path +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO, emit +from flask_cors import CORS +import yaml +import requests +from typing import Dict, List, Tuple, Optional +from datetime import datetime +from dataclasses import asdict + +# Import core functionality from main app +sys.path.insert(0, str(Path(__file__).parent.parent)) +from stock_tool_gui_v2 import ( + find_part, get_locations, get_part_info, get_part_parameters, + get_part_locations, get_location_details, find_stock_item, + get_stock_level, parse_scan, lookup_barcode, + PendingPart, ImportResult, PartImportWorker, MODE_NAMES, BARCODE_COMMANDS +) + +# Initialize Flask app +app = Flask(__name__) +app.config['SECRET_KEY'] = os.urandom(24) +CORS(app) +socketio = SocketIO(app, cors_allowed_origins="*") + +# Global state +config = {} +pending_parts: Dict[str, PendingPart] = {} +import_worker: Optional[PartImportWorker] = None + + +def load_config(): + """Load configuration from YAML file.""" + config_file = Path.home() / '.config' / 'scan_and_import.yaml' + if not config_file.exists(): + raise FileNotFoundError(f"Config file {config_file} not found") + + data = yaml.safe_load(config_file.read_text()) + host = data.get('host') + token = data.get('token') + + if not host or not token: + raise ValueError("Both 'host' and 'token' must be set in config file") + + return { + 'host': host.rstrip('/'), + 'token': token, + 'headers': { + 'Authorization': f'Token {token}', + 'Content-Type': 'application/json' + } + } + + +def on_import_complete(result: ImportResult): + """Handle import completion callback.""" + pending_part = pending_parts.get(result.part_code) + if not pending_part: + return + + if result.success: + # Emit success via WebSocket + socketio.emit('import_complete', { + 'part_code': result.part_code, + 'part_id': result.part_id, + 'success': True, + 'pending_part': asdict(pending_part) + }) + + # Remove from pending + del pending_parts[result.part_code] + else: + # Handle failure with retry + pending_part.retry_count += 1 + if pending_part.retry_count >= 3: + socketio.emit('import_failed', { + 'part_code': result.part_code, + 'error': result.error, + 'retry_count': pending_part.retry_count + }) + del pending_parts[result.part_code] + else: + # Retry + import_worker.queue_import(result.part_code) + socketio.emit('import_retry', { + 'part_code': result.part_code, + 'error': result.error, + 'retry_count': pending_part.retry_count + }) + + +# Routes +@app.route('/') +def index(): + """Render main application page.""" + return render_template('index.html') + + +@app.route('/api/config') +def get_config(): + """Get application configuration (without sensitive data).""" + return jsonify({ + 'host': config['host'], + 'modes': MODE_NAMES, + 'barcode_commands': BARCODE_COMMANDS + }) + + +@app.route('/api/locations') +def api_get_locations(): + """Get all available locations.""" + try: + locations = get_locations(config['host'], config['token']) + return jsonify({ + 'success': True, + 'locations': [{'id': loc_id, 'name': name} for loc_id, name in locations] + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/part/search', methods=['POST']) +def search_part(): + """Search for a part by code.""" + data = request.json + part_code = data.get('part_code') + + if not part_code: + return jsonify({'success': False, 'error': 'part_code required'}), 400 + + try: + part_id = find_part(config['host'], config['token'], part_code) + + if part_id: + # Get part details + part_info = get_part_info(config['host'], config['token'], part_id) + parameters = get_part_parameters(config['host'], config['token'], part_id) + + return jsonify({ + 'success': True, + 'found': True, + 'part_id': part_id, + 'part_info': part_info, + 'parameters': parameters + }) + else: + return jsonify({ + 'success': True, + 'found': False + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/part/import', methods=['POST']) +def queue_part_import(): + """Queue a part for background import.""" + data = request.json + part_code = data.get('part_code') + quantity = data.get('quantity') + location_id = data.get('location_id') + mode = data.get('mode', 'import') + + if not part_code: + return jsonify({'success': False, 'error': 'part_code required'}), 400 + + # Check if already queued + if part_code in pending_parts: + return jsonify({'success': False, 'error': 'Part already queued for import'}), 400 + + # Create pending part + pending_part = PendingPart( + part_code=part_code, + quantity=quantity, + location_id=location_id or 0, + mode=mode, + timestamp=datetime.now() + ) + pending_parts[part_code] = pending_part + + # Queue for import + import_worker.queue_import(part_code) + + return jsonify({ + 'success': True, + 'pending_part': asdict(pending_part) + }) + + +@app.route('/api/stock/add', methods=['POST']) +def add_stock(): + """Add stock for a part.""" + data = request.json + part_id = data.get('part_id') + location_id = data.get('location_id') + quantity = data.get('quantity') + + if not all([part_id, location_id, quantity]): + return jsonify({'success': False, 'error': 'part_id, location_id, and quantity required'}), 400 + + try: + # Check if stock item already exists + existing = find_stock_item(config['host'], config['token'], part_id, location_id) + current_stock = get_stock_level(config['host'], config['token'], part_id, location_id) + + if existing: + # Update existing + sid = existing.get('pk', existing.get('id')) + new_stock = current_stock + quantity + + r = requests.patch( + f"{config['host']}/api/stock/{sid}/", + headers=config['headers'], + json={'quantity': new_stock} + ) + r.raise_for_status() + + return jsonify({ + 'success': True, + 'action': 'updated', + 'stock_item_id': sid, + 'previous_quantity': current_stock, + 'new_quantity': new_stock, + 'added': quantity + }) + else: + # Create new + r = requests.post( + f"{config['host']}/api/stock/", + headers=config['headers'], + json={'part': part_id, 'location': location_id, 'quantity': quantity} + ) + r.raise_for_status() + + response_data = r.json() + if isinstance(response_data, list): + stock_item = response_data[0] if response_data else {} + else: + stock_item = response_data + + sid = stock_item.get('pk', stock_item.get('id')) + + return jsonify({ + 'success': True, + 'action': 'created', + 'stock_item_id': sid, + 'quantity': quantity + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock/update', methods=['POST']) +def update_stock(): + """Update stock quantity for a part.""" + data = request.json + part_id = data.get('part_id') + location_id = data.get('location_id') + quantity = data.get('quantity') + + if not all([part_id, location_id, quantity is not None]): + return jsonify({'success': False, 'error': 'part_id, location_id, and quantity required'}), 400 + + try: + existing = find_stock_item(config['host'], config['token'], part_id, location_id) + current_stock = get_stock_level(config['host'], config['token'], part_id, location_id) + + if existing: + sid = existing['pk'] + r = requests.patch( + f"{config['host']}/api/stock/{sid}/", + headers=config['headers'], + json={'quantity': quantity} + ) + r.raise_for_status() + + return jsonify({ + 'success': True, + 'action': 'updated', + 'stock_item_id': sid, + 'previous_quantity': current_stock, + 'new_quantity': quantity + }) + else: + # Create new + r = requests.post( + f"{config['host']}/api/stock/", + headers=config['headers'], + json={'part': part_id, 'location': location_id, 'quantity': quantity} + ) + r.raise_for_status() + + response_data = r.json() + if isinstance(response_data, list): + stock_item = response_data[0] if response_data else {} + else: + stock_item = response_data + + sid = stock_item.get('pk', stock_item.get('id')) + + return jsonify({ + 'success': True, + 'action': 'created', + 'stock_item_id': sid, + 'quantity': quantity + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/stock/check', methods=['POST']) +def check_stock(): + """Check stock level for a part at location.""" + data = request.json + part_id = data.get('part_id') + location_id = data.get('location_id') + + if not all([part_id, location_id]): + return jsonify({'success': False, 'error': 'part_id and location_id required'}), 400 + + try: + quantity = get_stock_level(config['host'], config['token'], part_id, location_id) + return jsonify({ + 'success': True, + 'quantity': quantity + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/part/locate', methods=['POST']) +def locate_part(): + """Find all locations where a part is stored.""" + data = request.json + part_id = data.get('part_id') + + if not part_id: + return jsonify({'success': False, 'error': 'part_id required'}), 400 + + try: + stock_items = get_part_locations(config['host'], config['token'], part_id) + + locations_info = [] + total_stock = 0 + + for item in stock_items: + loc_id = item.get('location') + quantity = item.get('quantity', 0) + total_stock += quantity + + if loc_id: + try: + location = get_location_details(config['host'], config['token'], loc_id) + loc_name = location.get('name', f'Location {loc_id}') + loc_path = location.get('pathstring', '') + + locations_info.append({ + 'location_id': loc_id, + 'location_name': loc_name, + 'location_path': loc_path, + 'quantity': quantity + }) + except Exception: + locations_info.append({ + 'location_id': loc_id, + 'location_name': f'Location {loc_id}', + 'location_path': '', + 'quantity': quantity + }) + + return jsonify({ + 'success': True, + 'locations': locations_info, + 'total_stock': total_stock + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/pending') +def get_pending(): + """Get all pending part imports.""" + return jsonify({ + 'success': True, + 'pending': [asdict(p) for p in pending_parts.values()] + }) + + +# WebSocket events +@socketio.on('connect') +def handle_connect(): + """Handle client connection.""" + emit('connected', {'message': 'Connected to InvenTree Stock Tool'}) + + +@socketio.on('disconnect') +def handle_disconnect(): + """Handle client disconnection.""" + print('Client disconnected') + + +@socketio.on('parse_barcode') +def handle_parse_barcode(data): + """Parse a scanned barcode.""" + raw_barcode = data.get('barcode', '') + part_code, quantity = parse_scan(raw_barcode) + + emit('barcode_parsed', { + 'raw': raw_barcode, + 'part_code': part_code, + 'quantity': quantity + }) + + +def main(): + """Main entry point for web application.""" + global config, import_worker + + # Load configuration + try: + config = load_config() + print(f"āœ… Connected to InvenTree: {config['host']}") + except Exception as e: + print(f"āœ– Configuration error: {e}") + sys.exit(1) + + # Initialize import worker + import_worker = PartImportWorker(config['host'], config['token'], on_import_complete) + + # Start Flask server + print("šŸš€ Starting InvenTree Stock Tool Web Server...") + print("šŸ“± Open your browser to: http://localhost:5000") + print("Press Ctrl+C to stop") + + try: + socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True) + except KeyboardInterrupt: + print("\nšŸ‘‹ Shutting down...") + import_worker.stop() + + +if __name__ == '__main__': + main() diff --git a/src/stocktool/web/static/js/app.js b/src/stocktool/web/static/js/app.js new file mode 100644 index 0000000..5f0d462 --- /dev/null +++ b/src/stocktool/web/static/js/app.js @@ -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'; + } + }; +} diff --git a/src/stocktool/web/templates/index.html b/src/stocktool/web/templates/index.html new file mode 100644 index 0000000..8e10f5a --- /dev/null +++ b/src/stocktool/web/templates/index.html @@ -0,0 +1,225 @@ + + + + + + InvenTree Stock Tool + + + + + + + + +
+
+
+
+ +

InvenTree Stock Tool

+
+
+ +
+ + +
+ +
+ + +
+
+
+
+
+ +
+ + +
+

+ + Location +

+
+
+ + +
+
+ + +
+
+
+ + Current Location: + +
+
+ + +
+

+ + Mode +

+
+ +
+
+ + +
+

+ + Scan Part +

+
+ + +
+
+ + +
+

+ + Pending Imports () +

+
+ + + + + + + + + + + + +
Part CodeQtyModeStatus
+
+
+ + +
+

+ + Part Information +

+
+ +
+
+ Part Code: + +
+
+ Name: + +
+
+ Description: + +
+ +
+ +
+ +
+
Current Stock
+
+
+
+
+
+ + +
+

+ + Activity Log +

+
+ +
+
+
+ + + +