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:
13
README.md
13
README.md
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
A comprehensive barcode scanning application for InvenTree inventory management.
|
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
|
## Features
|
||||||
|
|
||||||
- **Stock Addition**: Add stock to inventory by scanning barcodes
|
- **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
|
- **Async Part Import**: Non-blocking background import for unknown parts
|
||||||
- **Server Connection Monitoring**: Real-time connection status
|
- **Server Connection Monitoring**: Real-time connection status
|
||||||
- **Barcode Command Support**: Control the app via barcode commands
|
- **Barcode Command Support**: Control the app via barcode commands
|
||||||
|
- **Web Interface**: Access from any device with a browser (NEW!)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -29,10 +34,16 @@ cd stocktool
|
|||||||
# Install with uv
|
# Install with uv
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
# Run the application
|
# Run the DESKTOP application
|
||||||
uv run stock-tool
|
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
|
### Manual Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
254
WEB_APP.md
Normal file
254
WEB_APP.md
Normal file
@@ -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<location_id>`
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -11,12 +11,16 @@ dependencies = [
|
|||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
"requests>=2.31.0",
|
"requests>=2.31.0",
|
||||||
"pyyaml>=6.0.0",
|
"pyyaml>=6.0.0",
|
||||||
|
"flask>=3.0.0",
|
||||||
|
"flask-socketio>=5.3.0",
|
||||||
|
"flask-cors>=4.0.0",
|
||||||
]
|
]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
stock-tool = "stocktool.stock_tool_gui_v2:main"
|
stock-tool = "stocktool.stock_tool_gui_v2:main"
|
||||||
|
stock-tool-web = "stocktool.web.app:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
1
src/stocktool/web/__init__.py
Normal file
1
src/stocktool/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Web interface for InvenTree Stock Tool."""
|
||||||
456
src/stocktool/web/app.py
Normal file
456
src/stocktool/web/app.py
Normal file
@@ -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()
|
||||||
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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
225
src/stocktool/web/templates/index.html
Normal file
225
src/stocktool/web/templates/index.html
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>InvenTree Stock Tool</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-gray-100" x-data="stockApp()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-gray-800 shadow-lg">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-barcode text-3xl text-blue-500"></i>
|
||||||
|
<h1 class="text-2xl font-bold">InvenTree Stock Tool</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Connection Status -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i class="fas fa-circle text-xs" :class="connected ? 'text-green-500 animate-pulse' : 'text-red-500'"></i>
|
||||||
|
<span class="text-sm" x-text="connected ? 'Connected' : 'Disconnected'"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Server Info -->
|
||||||
|
<div class="text-sm text-gray-400" x-show="config.host">
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
<span x-text="config.host"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
<!-- Location Selection -->
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 flex items-center">
|
||||||
|
<i class="fas fa-map-marker-alt mr-2 text-blue-500"></i>
|
||||||
|
Location
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Scan or Select Location</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="locationInput"
|
||||||
|
@keyup.enter="processLocationScan"
|
||||||
|
placeholder="Scan location barcode or enter ID"
|
||||||
|
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Or Choose from List</label>
|
||||||
|
<select
|
||||||
|
x-model="currentLocation"
|
||||||
|
@change="onLocationChange"
|
||||||
|
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Select a location...</option>
|
||||||
|
<template x-for="loc in locations" :key="loc.id">
|
||||||
|
<option :value="loc.id" x-text="`${loc.id} - ${loc.name}`"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="currentLocation" class="mt-4 p-3 bg-blue-900 bg-opacity-30 rounded-lg border border-blue-700">
|
||||||
|
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||||
|
<span class="font-medium">Current Location: </span>
|
||||||
|
<span x-text="getCurrentLocationName()"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode Selection -->
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 flex items-center">
|
||||||
|
<i class="fas fa-cog mr-2 text-blue-500"></i>
|
||||||
|
Mode
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<template x-for="(name, key) in modes" :key="key">
|
||||||
|
<button
|
||||||
|
@click="currentMode = key"
|
||||||
|
:class="currentMode === key ? 'bg-blue-600 border-blue-500' : 'bg-gray-700 border-gray-600 hover:bg-gray-600'"
|
||||||
|
class="px-6 py-3 border-2 rounded-lg font-medium transition-all"
|
||||||
|
x-text="name"
|
||||||
|
></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part Scanning -->
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 flex items-center">
|
||||||
|
<i class="fas fa-qrcode mr-2 text-blue-500"></i>
|
||||||
|
Scan Part
|
||||||
|
</h2>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="scanInput"
|
||||||
|
@keyup.enter="processScan"
|
||||||
|
placeholder="Scan part barcode or enter part code..."
|
||||||
|
class="flex-1 px-4 py-3 text-lg bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
x-ref="scanInput"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="processScan"
|
||||||
|
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search mr-2"></i>
|
||||||
|
Process
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Imports -->
|
||||||
|
<div x-show="pendingParts.length > 0" class="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 flex items-center">
|
||||||
|
<i class="fas fa-clock mr-2 text-yellow-500"></i>
|
||||||
|
Pending Imports (<span x-text="pendingParts.length"></span>)
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left">Part Code</th>
|
||||||
|
<th class="px-4 py-2 text-left">Qty</th>
|
||||||
|
<th class="px-4 py-2 text-left">Mode</th>
|
||||||
|
<th class="px-4 py-2 text-left">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="part in pendingParts" :key="part.part_code">
|
||||||
|
<tr class="border-t border-gray-700">
|
||||||
|
<td class="px-4 py-2" x-text="part.part_code"></td>
|
||||||
|
<td class="px-4 py-2" x-text="part.quantity || 'N/A'"></td>
|
||||||
|
<td class="px-4 py-2" x-text="modes[part.mode] || part.mode"></td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 bg-yellow-900 text-yellow-300 rounded text-sm">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2"></i>
|
||||||
|
Importing... (attempt <span x-text="part.retry_count + 1"></span>)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Part Information -->
|
||||||
|
<div x-show="currentPart" class="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2 text-blue-500"></i>
|
||||||
|
Part Information
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Part Details -->
|
||||||
|
<div class="md:col-span-2 space-y-3">
|
||||||
|
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||||
|
<span class="font-medium">Part Code:</span>
|
||||||
|
<span x-text="currentPart?.IPN || 'N/A'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||||
|
<span class="font-medium">Name:</span>
|
||||||
|
<span x-text="currentPart?.name || 'N/A'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||||
|
<span class="font-medium">Description:</span>
|
||||||
|
<span x-text="currentPart?.description || 'N/A'"></span>
|
||||||
|
</div>
|
||||||
|
<template x-if="currentParameters && currentParameters.length > 0">
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="font-semibold mb-2">Parameters:</h3>
|
||||||
|
<template x-for="param in currentParameters" :key="param.pk">
|
||||||
|
<div class="flex justify-between py-2 border-b border-gray-700">
|
||||||
|
<span x-text="param.template_detail?.name || 'N/A'"></span>
|
||||||
|
<span x-text="param.data || 'N/A'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Image & Stock -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<template x-if="currentPart?.image || currentPart?.thumbnail">
|
||||||
|
<img
|
||||||
|
:src="currentPart.image || currentPart.thumbnail"
|
||||||
|
alt="Part Image"
|
||||||
|
class="w-full rounded-lg border border-gray-700"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<div x-show="currentStock !== null" class="text-center p-4 bg-blue-900 bg-opacity-30 rounded-lg border border-blue-700">
|
||||||
|
<div class="text-sm text-gray-400 mb-1">Current Stock</div>
|
||||||
|
<div class="text-3xl font-bold" x-text="currentStock"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Log -->
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow-xl p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 flex items-center">
|
||||||
|
<i class="fas fa-list mr-2 text-blue-500"></i>
|
||||||
|
Activity Log
|
||||||
|
</h2>
|
||||||
|
<div class="bg-gray-900 rounded-lg p-4 h-64 overflow-y-auto font-mono text-sm space-y-1" x-ref="logContainer">
|
||||||
|
<template x-for="(log, index) in logs" :key="index">
|
||||||
|
<div :class="getLogClass(log.type)">
|
||||||
|
<span class="text-gray-500" x-text="log.time"></span>
|
||||||
|
<span x-html="log.message"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user