added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data
This commit is contained in:
@@ -4,7 +4,15 @@
|
||||
"Bash(python manage.py migrate:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(dir /b \"c:\\\\dev_projects\\\\ScalesApp\\\\frontend\\\\src\\\\contexts\")",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(python manage.py makemigrations:*)",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(python manage.py:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=scalesapp.settings python:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## System Overview
|
||||
|
||||
ScalesApp is a three-tier weighing scale management system with real-time data synchronization:
|
||||
- **Django Backend** (REST API + Server-Sent Events) on port 8000
|
||||
- **React Frontend** (SPA) on port 3000
|
||||
- **Serial Bridge** (Flask SSE server) on port 5000 for COM port reading
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (Django)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows: venv\Scripts\activate | Unix: source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Database operations
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser
|
||||
|
||||
# Run server
|
||||
python manage.py runserver # Development (WSGI)
|
||||
uvicorn scalesapp.asgi:application --reload # Development (ASGI - for SSE)
|
||||
|
||||
# Testing (when implemented)
|
||||
python manage.py test
|
||||
python manage.py test api.tests.test_models # Single test file
|
||||
```
|
||||
|
||||
### Frontend (React)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm start # Development server
|
||||
npm run build # Production build
|
||||
npm test # Run tests
|
||||
```
|
||||
|
||||
### Serial Bridge
|
||||
|
||||
```bash
|
||||
cd serial_bridge
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
python app.py # Run Flask SSE server
|
||||
|
||||
# Build standalone executable
|
||||
pyinstaller serial_bridge.spec
|
||||
```
|
||||
|
||||
### Testing COM Port (requires com0com)
|
||||
|
||||
```bash
|
||||
cd test_comport_writer
|
||||
python test_writer.py # Sends test data to virtual COM port
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Django Backend Structure
|
||||
|
||||
**Three Django Apps:**
|
||||
- **`api/`** - Core API, user management, COM port readings, report templates
|
||||
- **`vehicles/`** - Vehicle weighing management with dynamic nomenclature data
|
||||
- **`nomenclatures/`** - Runtime-configurable schema system (lookup tables and custom fields)
|
||||
|
||||
**Custom User Model:** `api.User` (extends AbstractUser)
|
||||
- Fields: `role` (employee/viewer), `is_admin` (boolean)
|
||||
- All authentication requires JWT tokens
|
||||
|
||||
**Database:** PostgreSQL (production), SQLite (development fallback)
|
||||
|
||||
**Key Models:**
|
||||
- `User` - Custom user with roles
|
||||
- `ComPortReading` - Serial port data log
|
||||
- `Report` - Report templates (JSONField stores element definitions)
|
||||
- `Vehicle` - Core weighing entity (vehicle_number, tare, gross, with user/timestamp tracking)
|
||||
- `VehicleExtra` - OneToOne with Vehicle, JSONField for dynamic nomenclature data
|
||||
- `Nomenclature` - Configurable data types (applies_to: vehicle/container, kind: lookup/field)
|
||||
- `NomenclatureField` - Field definitions (text, number, bool, choice types)
|
||||
- `NomenclatureEntry` - Data entries for lookup tables
|
||||
|
||||
**ViewSet Pattern:** All views use DRF's ModelViewSet with custom actions via `@action` decorator
|
||||
- Examples: `@action(detail=True, methods=['post']) def set_tare()`
|
||||
|
||||
### Real-Time Communication (SSE)
|
||||
|
||||
**Implementation:** `backend/scalesapp/sse.py`
|
||||
- ASGI-based async SSE endpoint: `/sse-connect/`
|
||||
- JWT authentication via query parameter or Authorization header
|
||||
- Global `updates_queue` with 100-event buffer
|
||||
- Event ID tracking for reconnection/replay of missed events
|
||||
- `sse_broadcast_update(operation, model, data)` called from views after mutations
|
||||
- Auto-reconnect with Last-Event-ID header support
|
||||
|
||||
**Usage in Views:**
|
||||
```python
|
||||
from scalesapp.sse import sse_broadcast_update
|
||||
|
||||
def create(self, request):
|
||||
# ... create logic ...
|
||||
sse_broadcast_update('insert', 'vehicle', serializer.data)
|
||||
```
|
||||
|
||||
### Nomenclature System (Dynamic Schema)
|
||||
|
||||
**Purpose:** Runtime-configurable extra fields without database migrations
|
||||
|
||||
**Flow:**
|
||||
1. Admin creates `Nomenclature` (e.g., "CARGO_TYPE", kind=lookup, applies_to=vehicle)
|
||||
2. Defines `NomenclatureField`s (e.g., name: text, code: text)
|
||||
3. Creates `NomenclatureEntry` records (e.g., "Coal", "Iron Ore")
|
||||
4. Frontend loads nomenclatures on mount via REST API
|
||||
5. User selects from dropdown → stores entry ID in `vehicle.extra.data.CARGO_TYPE` (JSONField)
|
||||
6. Validation in `VehicleExtraSerializer` enforces type constraints
|
||||
|
||||
### React Frontend Structure
|
||||
|
||||
**Context Providers (State Management):**
|
||||
- **`AuthContext`** - JWT token management, auto-refresh every 14 minutes, axios interceptor for 401 handling
|
||||
- **`NomenclatureContext`** - Centralized data cache with SSE subscription to Django `/sse-connect/`
|
||||
- Real-time updates (insert/update operations)
|
||||
- Maintains sorted arrays for all nomenclatures
|
||||
- Auto-reconnect on connection loss
|
||||
- Parallel loading of all nomenclature endpoints on mount
|
||||
- **`DataContext`** - Report editor data fetching (for preview mode)
|
||||
- **`BandContext`** - Report editor band iteration state
|
||||
|
||||
**Key Components:**
|
||||
- **`Main.jsx`** - Primary weighing interface
|
||||
- Left panel: Vehicle list with search
|
||||
- Right panel: Selected vehicle details with dynamic nomenclature fields
|
||||
- Real-time weight display from serial bridge
|
||||
- Tare/Gross weight setting with user/timestamp tracking
|
||||
- SSE-based automatic UI updates
|
||||
- **`ReportEditor/`** - Custom report builder (see Report Editor section)
|
||||
|
||||
**API Communication:** `services/api.js`
|
||||
- Axios instance with JWT interceptor
|
||||
- Token refresh queue (pauses failed requests during refresh)
|
||||
- Auto-logout on refresh failure
|
||||
|
||||
**Routing:**
|
||||
- `/` - Main weighing interface (protected)
|
||||
- `/report-editor` - Report designer (protected)
|
||||
- Shows Login component if not authenticated
|
||||
|
||||
### Serial Bridge Architecture
|
||||
|
||||
**Components:**
|
||||
- `app.py` - Flask SSE server on port 5000
|
||||
- `serial_reader.py` - SerialPortReader class with threading
|
||||
- `tray_icon.py` - Windows system tray integration
|
||||
|
||||
**Data Flow:**
|
||||
1. Thread reads from COM port continuously (pyserial)
|
||||
2. Data queued in global `data_queue`
|
||||
3. SSE endpoint `/events` streams to frontend
|
||||
4. Frontend's `useSerialData` hook receives updates
|
||||
5. **Note:** Serial bridge is independent of Django (direct SSE to frontend)
|
||||
|
||||
**Configuration:** `.env` file with COM_PORT, BAUD_RATE, TIMEOUT, READ_INTERVAL
|
||||
|
||||
### Real-Time Data Flow Example
|
||||
|
||||
```
|
||||
COM Port → Serial Bridge (Flask SSE :5000) → Frontend useSerialData hook
|
||||
↓
|
||||
Main.jsx displays weight
|
||||
↓
|
||||
User clicks "Set Tare"
|
||||
↓
|
||||
POST /api/vehicles/{id}/set-tare/
|
||||
↓
|
||||
Django sse_broadcast_update()
|
||||
↓
|
||||
SSE → Frontend NomenclatureContext
|
||||
↓
|
||||
Vehicle list auto-updates
|
||||
```
|
||||
|
||||
## Report Editor System
|
||||
|
||||
**Architecture:** Custom drag-drop report designer with grid-based layout
|
||||
|
||||
**Grid System:**
|
||||
- Page dimensions: 80 columns × 66 rows (character grid)
|
||||
- All elements positioned in grid coordinates
|
||||
- Pixel conversion: `x * charWidth`, `y * charHeight`
|
||||
|
||||
**Element Types (OOP Class Hierarchy):**
|
||||
```
|
||||
Element (base class)
|
||||
├── TextElement (static text)
|
||||
├── DBTextElement (data-bound field with objectKey/fieldPath)
|
||||
├── FrameElement (borders with box-drawing characters)
|
||||
├── HorizontalLineElement
|
||||
├── VerticalLineElement
|
||||
├── SymbolElement (single character)
|
||||
└── BandElement (repeating section with children[])
|
||||
```
|
||||
|
||||
**Band Types:**
|
||||
- `header` - Page header
|
||||
- `footer` - Page footer
|
||||
- `detail` - Main repeating data section
|
||||
- `subdetail` - Nested repeating (child of detail)
|
||||
- `summary` - Summary/totals section
|
||||
|
||||
**Key Components:**
|
||||
- `ReportEditor.jsx` - Main orchestrator with save/load to Django `/api/reports/`
|
||||
- `EditorCanvas.jsx` - Canvas rendering, drag/drop, multi-select
|
||||
- `Toolbar.jsx` - Tool selection (text, frame, lines, bands, etc.)
|
||||
- `ObjectInspector.jsx` - Property editor for selected elements
|
||||
- `ConfigPanel.jsx` - API endpoint configuration
|
||||
- `CharacterPalette.jsx` - Box-drawing character picker
|
||||
- `models/Element.jsx` - Element class definitions
|
||||
|
||||
**Data Binding:**
|
||||
- `DBTextElement` has `objectKey` and `fieldPath`
|
||||
- Example: objectKey="invoice", fieldPath="customer.name"
|
||||
- Preview mode fetches data from configured `apiEndpoint`
|
||||
- BandContext provides current item during band iteration
|
||||
|
||||
**Persistence:**
|
||||
- Entire report serialized to JSON
|
||||
- Saved to Django `Report` model (JSONField stores elements array)
|
||||
|
||||
**Features:**
|
||||
- Multi-select with drag/drop
|
||||
- Resize handles
|
||||
- Keyboard shortcuts (Delete, Ctrl+S, etc.)
|
||||
- Grid snapping
|
||||
- Character palette for box-drawing
|
||||
- Preview mode with live data
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
**JWT Token Flow:**
|
||||
- Access token: 15 minutes lifetime
|
||||
- Refresh token: 7 days lifetime
|
||||
- Stored in localStorage (XSS risk - consider httpOnly cookies for production)
|
||||
- Frontend auto-refreshes tokens every 14 minutes
|
||||
- All API requests require Bearer token (except `/api/token/` and `/api/token/refresh/`)
|
||||
- SSE connection authenticated via query param: `/sse-connect/?token=<jwt>` or Authorization header
|
||||
|
||||
**CORS Configuration:**
|
||||
- Allowed origins: http://localhost:3000, http://127.0.0.1:3000, http://localhost:5174
|
||||
- Credentials allowed (for cookies if needed)
|
||||
|
||||
**User Roles:**
|
||||
- `employee` - Full access to weighing operations
|
||||
- `viewer` - Read-only access
|
||||
- `is_admin` - Access to Django admin panel
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
**Why SSE instead of WebSockets?**
|
||||
- Simpler server implementation (one-way communication sufficient)
|
||||
- Auto-reconnect built into EventSource API
|
||||
- Event ID-based replay for missed events
|
||||
- HTTP-based (easier to proxy/firewall)
|
||||
|
||||
**Why ASGI?**
|
||||
- Required for async SSE endpoint
|
||||
- Uses uvicorn server
|
||||
- `sync_to_async` wrapper for ORM queries in SSE handler
|
||||
|
||||
**Why Separate Serial Bridge?**
|
||||
- Independence from Django (can restart Django without losing COM connection)
|
||||
- System tray integration for user control
|
||||
- Portable executable deployment
|
||||
- Dedicated threading for serial I/O
|
||||
|
||||
**Why Nomenclature System?**
|
||||
- Avoid schema migrations for custom fields
|
||||
- Different deployments have different requirements
|
||||
- User-configurable without code changes
|
||||
- Validation still enforced via serializers
|
||||
|
||||
## Important Files for Understanding Architecture
|
||||
|
||||
1. `backend/scalesapp/settings.py` - Django configuration, JWT, CORS, database
|
||||
2. `backend/scalesapp/sse.py` - Real-time SSE implementation
|
||||
3. `backend/api/models.py` - Core models (User, ComPortReading, Report)
|
||||
4. `backend/vehicles/models.py` - Vehicle and VehicleExtra with JSONField
|
||||
5. `backend/nomenclatures/models.py` - Dynamic schema system
|
||||
6. `frontend/src/App.jsx` - Routing and AuthProvider setup
|
||||
7. `frontend/src/contexts/NomenclatureContext.jsx` - SSE client and data cache
|
||||
8. `frontend/src/contexts/AuthContext.jsx` - JWT token management
|
||||
9. `frontend/src/components/Main.jsx` - Primary weighing interface
|
||||
10. `frontend/src/components/ReportEditor/ReportEditor.jsx` - Report builder
|
||||
11. `serial_bridge/app.py` - Flask SSE server for COM port
|
||||
|
||||
## Configuration Files
|
||||
|
||||
**Backend:** `backend/.env`
|
||||
```
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
DB_NAME=scalesapp
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
```
|
||||
|
||||
**Frontend:** `frontend/.env`
|
||||
```
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
**Serial Bridge:** `serial_bridge/.env`
|
||||
```
|
||||
COM_PORT=COM1
|
||||
BAUD_RATE=9600
|
||||
BACKEND_URL=http://localhost:8000
|
||||
AUTO_CONNECT=True
|
||||
DEBUG=False
|
||||
```
|
||||
|
||||
## Running the Full Application
|
||||
|
||||
1. Start Django: `cd backend && python manage.py runserver`
|
||||
2. Start Serial Bridge: `cd serial_bridge && python app.py`
|
||||
3. Start React: `cd frontend && npm start`
|
||||
|
||||
For SSE to work properly in Django, use uvicorn instead: `uvicorn scalesapp.asgi:application --reload`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Adding a new model:**
|
||||
1. Define model in appropriate app (api/vehicles/nomenclatures)
|
||||
2. Create serializer in `serializers.py`
|
||||
3. Create ViewSet in `views.py` with `sse_broadcast_update()` calls
|
||||
4. Register route in `urls.py`
|
||||
5. Run `python manage.py makemigrations && python manage.py migrate`
|
||||
6. Update frontend Context to subscribe to SSE updates
|
||||
|
||||
**Adding a new nomenclature field:**
|
||||
1. Create Nomenclature via Django admin or API
|
||||
2. Define NomenclatureFields
|
||||
3. Frontend auto-loads on next mount
|
||||
4. No code changes needed (dynamic rendering in Main.jsx)
|
||||
|
||||
**Adding a report element type:**
|
||||
1. Create new Element subclass in `models/Element.jsx`
|
||||
2. Add component file (e.g., `MyElement.jsx`)
|
||||
3. Register in `EditorCanvas.jsx` render switch
|
||||
4. Add tool button in `Toolbar.jsx`
|
||||
5. Add properties in `ObjectInspector.jsx`
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 4.2.8 on 2026-02-05 23:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("api", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Report",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("page_width", models.IntegerField(default=80)),
|
||||
("page_height", models.IntegerField(default=66)),
|
||||
(
|
||||
"api_endpoint",
|
||||
models.CharField(blank=True, default="", max_length=500),
|
||||
),
|
||||
("elements", models.JSONField(default=list)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reports",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+23
-2
@@ -26,13 +26,34 @@ class ComPortReading(models.Model):
|
||||
data = models.TextField()
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
source_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['-timestamp']),
|
||||
models.Index(fields=['port', '-timestamp']),
|
||||
]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.port} - {self.timestamp}"
|
||||
|
||||
|
||||
class Report(models.Model):
|
||||
"""Model to store report templates for the report editor"""
|
||||
name = models.CharField(max_length=255)
|
||||
page_width = models.IntegerField(default=80)
|
||||
page_height = models.IntegerField(default=66)
|
||||
api_endpoint = models.CharField(max_length=500, blank=True, default='')
|
||||
elements = models.JSONField(default=list)
|
||||
created_by = models.ForeignKey(
|
||||
User, on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='reports'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
+160
-21
@@ -1,8 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from .models import ComPortReading, User
|
||||
from .models import ComPortReading, User, Report
|
||||
from vehicles.models import Vehicle, VehicleExtra
|
||||
from nomenclatures.models import Nomenclature, NomenclatureField
|
||||
from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -80,14 +80,58 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'data']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def validate_data(self, value):
|
||||
if not value:
|
||||
return value
|
||||
|
||||
nomenclatures = {
|
||||
n.code: n for n in
|
||||
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries')
|
||||
}
|
||||
|
||||
errors = {}
|
||||
for key, val in value.items():
|
||||
if key not in nomenclatures:
|
||||
errors[key] = f"Unknown nomenclature code '{key}'."
|
||||
continue
|
||||
|
||||
nom = nomenclatures[key]
|
||||
|
||||
if nom.kind == Nomenclature.LOOKUP:
|
||||
if not isinstance(val, int):
|
||||
errors[key] = "Must be a nomenclature entry ID (integer)."
|
||||
elif not nom.entries.filter(id=val, is_active=True).exists():
|
||||
errors[key] = f"Entry ID {val} not found in '{nom.code}'."
|
||||
|
||||
elif nom.kind == Nomenclature.FIELD:
|
||||
field_def = nom.fields.first()
|
||||
if field_def:
|
||||
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
|
||||
errors[key] = "Must be a string."
|
||||
elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)):
|
||||
errors[key] = "Must be a number."
|
||||
elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool):
|
||||
errors[key] = "Must be a boolean."
|
||||
elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []):
|
||||
errors[key] = f"Must be one of {field_def.choices}."
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class VehicleSerializer(serializers.ModelSerializer):
|
||||
extra = VehicleExtraSerializer(required=False, allow_null=True)
|
||||
tare_user_name = serializers.CharField(source='tare_user.username', read_only=True, default=None)
|
||||
gross_user_name = serializers.CharField(source='gross_user.username', read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = Vehicle
|
||||
fields = ['id', 'vehicle_number', 'extra']
|
||||
read_only_fields = ['id']
|
||||
fields = ['id', 'vehicle_number', 'tare', 'tare_date', 'tare_user', 'tare_user_name',
|
||||
'gross', 'gross_date', 'gross_user', 'gross_user_name', 'extra']
|
||||
read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name',
|
||||
'gross_date', 'gross_user', 'gross_user_name']
|
||||
|
||||
def create(self, validated_data):
|
||||
extra_data = validated_data.pop('extra', None)
|
||||
@@ -101,19 +145,17 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
def update(self, instance, validated_data):
|
||||
extra_data = validated_data.pop('extra', None)
|
||||
|
||||
# Update Vehicle fields
|
||||
instance.vehicle_number = validated_data.get('vehicle_number', instance.vehicle_number)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
# Handle VehicleExtra update/creation
|
||||
if extra_data is not None:
|
||||
if hasattr(instance, 'extra'):
|
||||
# Update existing VehicleExtra
|
||||
for attr, value in extra_data.items():
|
||||
setattr(instance.extra, attr, value)
|
||||
instance.extra.save()
|
||||
else:
|
||||
# Create new VehicleExtra
|
||||
VehicleExtra.objects.create(vehicle=instance, **extra_data)
|
||||
|
||||
return instance
|
||||
@@ -127,7 +169,7 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
class NomenclatureFieldSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NomenclatureField
|
||||
fields = ['id', 'key', 'field_type']
|
||||
fields = ['id', 'key', 'label', 'field_type', 'required', 'choices', 'sort_order']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def validate_key(self, value):
|
||||
@@ -143,17 +185,80 @@ class NomenclatureFieldSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('field_type') == NomenclatureField.CHOICE:
|
||||
choices = data.get('choices')
|
||||
if not choices or not isinstance(choices, list) or len(choices) == 0:
|
||||
raise serializers.ValidationError({
|
||||
'choices': 'Choices must be a non-empty list for choice fields.'
|
||||
})
|
||||
elif data.get('choices'):
|
||||
raise serializers.ValidationError({
|
||||
'choices': 'Only choice-type fields can have choices.'
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
class NomenclatureEntrySerializer(serializers.ModelSerializer):
|
||||
display_value = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = NomenclatureEntry
|
||||
fields = ['id', 'data', 'is_active', 'display_value']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def get_display_value(self, obj):
|
||||
display_key = obj.nomenclature.display_field or "name"
|
||||
return obj.data.get(display_key, f"Entry #{obj.pk}")
|
||||
|
||||
def validate_data(self, value):
|
||||
nomenclature = self.context.get('nomenclature')
|
||||
if not nomenclature:
|
||||
return value
|
||||
|
||||
field_defs = {f.key: f for f in nomenclature.fields.all()}
|
||||
|
||||
for key in value.keys():
|
||||
if key not in field_defs:
|
||||
raise serializers.ValidationError(
|
||||
f"Unknown field '{key}'. Valid fields: {list(field_defs.keys())}"
|
||||
)
|
||||
|
||||
for key, field_def in field_defs.items():
|
||||
if field_def.required and key not in value:
|
||||
raise serializers.ValidationError(
|
||||
f"Required field '{key}' is missing."
|
||||
)
|
||||
|
||||
for key, val in value.items():
|
||||
if key not in field_defs:
|
||||
continue
|
||||
field_def = field_defs[key]
|
||||
|
||||
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
|
||||
raise serializers.ValidationError(f"Field '{key}' must be a string.")
|
||||
elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)):
|
||||
raise serializers.ValidationError(f"Field '{key}' must be a number.")
|
||||
elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool):
|
||||
raise serializers.ValidationError(f"Field '{key}' must be a boolean.")
|
||||
elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []):
|
||||
raise serializers.ValidationError(f"Field '{key}' must be one of {field_def.choices}.")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class NomenclatureSerializer(serializers.ModelSerializer):
|
||||
fields = NomenclatureFieldSerializer(many=True, required=False, source='nomenclaturefield_set')
|
||||
fields = NomenclatureFieldSerializer(many=True, required=False)
|
||||
entry_count = serializers.IntegerField(source='entries.count', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Nomenclature
|
||||
fields = ['id', 'code', 'name', 'applies_to', 'fields']
|
||||
fields = ['id', 'code', 'name', 'applies_to', 'kind', 'display_field',
|
||||
'sort_order', 'fields', 'entry_count']
|
||||
read_only_fields = ['id']
|
||||
|
||||
def create(self, validated_data):
|
||||
fields_data = validated_data.pop('nomenclaturefield_set', [])
|
||||
fields_data = validated_data.pop('fields', [])
|
||||
nomenclature = Nomenclature.objects.create(**validated_data)
|
||||
|
||||
for field_data in fields_data:
|
||||
@@ -162,19 +267,14 @@ class NomenclatureSerializer(serializers.ModelSerializer):
|
||||
return nomenclature
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
fields_data = validated_data.pop('nomenclaturefield_set', None)
|
||||
fields_data = validated_data.pop('fields', None)
|
||||
|
||||
# Update Nomenclature fields
|
||||
instance.code = validated_data.get('code', instance.code)
|
||||
instance.name = validated_data.get('name', instance.name)
|
||||
instance.applies_to = validated_data.get('applies_to', instance.applies_to)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
|
||||
# Handle fields update - replace all fields
|
||||
if fields_data is not None:
|
||||
# Delete existing fields
|
||||
instance.nomenclaturefield_set.all().delete()
|
||||
# Create new fields
|
||||
instance.fields.all().delete()
|
||||
for field_data in fields_data:
|
||||
NomenclatureField.objects.create(nomenclature=instance, **field_data)
|
||||
|
||||
@@ -192,3 +292,42 @@ class NomenclatureSerializer(serializers.ModelSerializer):
|
||||
f"Invalid applies_to value. Must be one of: {', '.join(valid_choices)}"
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP))
|
||||
fields_data = data.get('fields', [])
|
||||
|
||||
if kind == Nomenclature.FIELD and fields_data and len(fields_data) != 1:
|
||||
raise serializers.ValidationError(
|
||||
"A 'field' nomenclature must have exactly one field definition."
|
||||
)
|
||||
|
||||
if kind == Nomenclature.LOOKUP:
|
||||
display_field = data.get('display_field', getattr(self.instance, 'display_field', 'name'))
|
||||
field_keys = [f['key'] for f in fields_data] if fields_data else []
|
||||
if field_keys and display_field not in field_keys:
|
||||
raise serializers.ValidationError(
|
||||
f"display_field '{display_field}' must match one of the defined field keys."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ReportSerializer(serializers.ModelSerializer):
|
||||
created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = Report
|
||||
fields = ['id', 'name', 'page_width', 'page_height', 'api_endpoint',
|
||||
'elements', 'created_by', 'created_by_name', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_by', 'created_by_name', 'created_at', 'updated_at']
|
||||
|
||||
def validate_name(self, value):
|
||||
if not value or not value.strip():
|
||||
raise serializers.ValidationError("Report name cannot be empty")
|
||||
return value.strip()
|
||||
|
||||
def validate_elements(self, value):
|
||||
if not isinstance(value, list):
|
||||
raise serializers.ValidationError("Elements must be a list")
|
||||
return value
|
||||
|
||||
@@ -11,6 +11,8 @@ router.register(r'users', views.UserViewSet, basename='user')
|
||||
router.register(r'readings', views.ComPortReadingViewSet, basename='reading')
|
||||
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
|
||||
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
|
||||
router.register(r'nomenclature-entries', views.NomenclatureEntryViewSet, basename='nomenclature-entry')
|
||||
router.register(r'reports', views.ReportViewSet, basename='report')
|
||||
|
||||
urlpatterns = [
|
||||
# JWT token endpoints
|
||||
|
||||
+168
-11
@@ -2,10 +2,16 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from .models import ComPortReading, User
|
||||
from .serializers import ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer
|
||||
from .models import ComPortReading, User, Report
|
||||
from .serializers import (
|
||||
ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
|
||||
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
|
||||
NomenclatureEntrySerializer, ReportSerializer,
|
||||
)
|
||||
from django.utils import timezone
|
||||
from vehicles.models import Vehicle
|
||||
from nomenclatures.models import Nomenclature
|
||||
from nomenclatures.models import Nomenclature, NomenclatureEntry
|
||||
from scalesapp.sse import sse_broadcast_update
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
@@ -77,7 +83,7 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||
serializer = self.get_serializer(reading)
|
||||
return Response(serializer.data)
|
||||
return Response({'detail': 'No readings yet'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def by_port(self, request):
|
||||
"""Get readings for a specific port"""
|
||||
@@ -90,7 +96,7 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||
readings = ComPortReading.objects.filter(port=port)
|
||||
serializer = self.get_serializer(readings, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
def get_client_ip(self, request):
|
||||
"""Get client IP address"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
@@ -99,7 +105,7 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Save the reading with client IP"""
|
||||
serializer.save(source_ip=self.get_client_ip(self.request))
|
||||
@@ -116,14 +122,31 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
partial_update: Partial update of vehicle
|
||||
destroy: Delete a vehicle (cascades to VehicleExtra)
|
||||
by_number: Get vehicle by vehicle_number
|
||||
set_tare: Set tare weight from scale reading
|
||||
set_gross: Set gross weight from scale reading
|
||||
"""
|
||||
queryset = Vehicle.objects.select_related('extra').all()
|
||||
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
|
||||
serializer_class = VehicleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['vehicle_number']
|
||||
search_fields = ['vehicle_number']
|
||||
ordering = ['vehicle_number']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
data = VehicleSerializer(instance).data
|
||||
sse_broadcast_update('vehicle', 'created', data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
data = VehicleSerializer(instance).data
|
||||
sse_broadcast_update('vehicle', 'updated', data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
data = VehicleSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('vehicle', 'deleted', data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-number')
|
||||
def by_number(self, request):
|
||||
"""Get vehicle by vehicle_number query parameter"""
|
||||
@@ -144,6 +167,48 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='set-tare')
|
||||
def set_tare(self, request, pk=None):
|
||||
"""Set tare weight from scale reading"""
|
||||
vehicle = self.get_object()
|
||||
value = request.data.get('value')
|
||||
if value is None:
|
||||
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
vehicle.tare = value
|
||||
vehicle.tare_date = timezone.now()
|
||||
vehicle.tare_user = request.user
|
||||
vehicle.save()
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='set-gross')
|
||||
def set_gross(self, request, pk=None):
|
||||
"""Set gross weight from scale reading"""
|
||||
vehicle = self.get_object()
|
||||
value = request.data.get('value')
|
||||
if value is None:
|
||||
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
value = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
vehicle.gross = value
|
||||
vehicle.gross_date = timezone.now()
|
||||
vehicle.gross_user = request.user
|
||||
vehicle.save()
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@@ -154,16 +219,30 @@ class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||
retrieve: Get a specific nomenclature by ID
|
||||
update: Full update of nomenclature and fields
|
||||
partial_update: Partial update of nomenclature
|
||||
destroy: Delete a nomenclature (cascades to fields)
|
||||
destroy: Delete a nomenclature (cascades to fields and entries)
|
||||
by_code: Get nomenclature by code
|
||||
by_applies_to: Filter nomenclatures by applies_to type
|
||||
entries: List or create entries for a specific nomenclature
|
||||
"""
|
||||
queryset = Nomenclature.objects.prefetch_related('nomenclaturefield_set').all()
|
||||
queryset = Nomenclature.objects.prefetch_related('fields').all()
|
||||
serializer_class = NomenclatureSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['applies_to', 'code']
|
||||
filterset_fields = ['applies_to', 'code', 'kind']
|
||||
search_fields = ['code', 'name']
|
||||
ordering = ['code']
|
||||
ordering = ['sort_order', 'code']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
sse_broadcast_update('nomenclature', 'insert', NomenclatureSerializer(instance).data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
sse_broadcast_update('nomenclature', 'update', NomenclatureSerializer(instance).data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
data = NomenclatureSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('nomenclature', 'delete', data)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-code')
|
||||
def by_code(self, request):
|
||||
@@ -209,3 +288,81 @@ class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||
|
||||
serializer = self.get_serializer(nomenclatures, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get', 'post'], url_path='entries')
|
||||
def entries(self, request, pk=None):
|
||||
"""List or create entries for a specific nomenclature."""
|
||||
nomenclature = self.get_object()
|
||||
|
||||
if request.method == 'GET':
|
||||
entries = nomenclature.entries.all()
|
||||
is_active = request.query_params.get('is_active')
|
||||
if is_active is not None:
|
||||
entries = entries.filter(is_active=is_active.lower() == 'true')
|
||||
serializer = NomenclatureEntrySerializer(entries, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
serializer = NomenclatureEntrySerializer(
|
||||
data=request.data,
|
||||
context={'nomenclature': nomenclature},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(nomenclature=nomenclature)
|
||||
entry_data = {**serializer.data, 'nomenclature_code': nomenclature.code}
|
||||
sse_broadcast_update('nomenclature_entry', 'insert', entry_data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class NomenclatureEntryViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for individual nomenclature entry management.
|
||||
For list/create use the nested endpoint: /api/nomenclatures/{id}/entries/
|
||||
This viewset handles retrieve/update/delete of individual entries.
|
||||
"""
|
||||
queryset = NomenclatureEntry.objects.select_related('nomenclature').all()
|
||||
serializer_class = NomenclatureEntrySerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
if self.kwargs.get('pk'):
|
||||
try:
|
||||
entry = NomenclatureEntry.objects.select_related('nomenclature').get(pk=self.kwargs['pk'])
|
||||
context['nomenclature'] = entry.nomenclature
|
||||
except NomenclatureEntry.DoesNotExist:
|
||||
pass
|
||||
return context
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
entry_data = {**NomenclatureEntrySerializer(instance).data, 'nomenclature_code': instance.nomenclature.code}
|
||||
sse_broadcast_update('nomenclature_entry', 'update', entry_data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
entry_data = {**NomenclatureEntrySerializer(instance).data, 'nomenclature_code': instance.nomenclature.code}
|
||||
instance.delete()
|
||||
sse_broadcast_update('nomenclature_entry', 'delete', entry_data)
|
||||
|
||||
|
||||
class ReportViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for report template management.
|
||||
|
||||
list: Get all reports
|
||||
create: Create a new report
|
||||
retrieve: Get a specific report by ID
|
||||
update: Update a report
|
||||
destroy: Delete a report
|
||||
"""
|
||||
queryset = Report.objects.select_related('created_by').all()
|
||||
serializer_class = ReportSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['name']
|
||||
search_fields = ['name']
|
||||
ordering = ['-updated_at']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from .models import Nomenclature, NomenclatureField, NomenclatureEntry
|
||||
|
||||
# Register your models here.
|
||||
|
||||
class NomenclatureFieldInline(admin.TabularInline):
|
||||
model = NomenclatureField
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Nomenclature)
|
||||
class NomenclatureAdmin(admin.ModelAdmin):
|
||||
list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order']
|
||||
list_filter = ['applies_to', 'kind']
|
||||
search_fields = ['code', 'name']
|
||||
inlines = [NomenclatureFieldInline]
|
||||
|
||||
|
||||
@admin.register(NomenclatureEntry)
|
||||
class NomenclatureEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', '__str__', 'nomenclature', 'is_active']
|
||||
list_filter = ['nomenclature', 'is_active']
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Generated by Django 4.2.8 on 2026-02-05 15:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("nomenclatures", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="nomenclature",
|
||||
options={"ordering": ["sort_order", "code"]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="nomenclaturefield",
|
||||
options={"ordering": ["sort_order", "key"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclature",
|
||||
name="display_field",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="name",
|
||||
help_text="Key of the NomenclatureField to use as dropdown label (for lookup kind)",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclature",
|
||||
name="kind",
|
||||
field=models.CharField(
|
||||
choices=[("lookup", "Lookup Table"), ("field", "Custom Field")],
|
||||
default="lookup",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclature",
|
||||
name="sort_order",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="choices",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
help_text='List of allowed values for choice type, e.g. ["Red","Green","Blue"]',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="label",
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="required",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="nomenclaturefield",
|
||||
name="sort_order",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="nomenclaturefield",
|
||||
name="nomenclature",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="fields",
|
||||
to="nomenclatures.nomenclature",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="nomenclaturefield",
|
||||
unique_together={("nomenclature", "key")},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="NomenclatureEntry",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("data", models.JSONField(default=dict)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"nomenclature",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="entries",
|
||||
to="nomenclatures.nomenclature",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Nomenclature entries",
|
||||
"ordering": ["id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,13 +1,39 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class Nomenclature(models.Model):
|
||||
VEHICLE = "vehicle"
|
||||
CONTAINER = "container"
|
||||
APPLIES_TO_CHOICES = [
|
||||
(VEHICLE, "Vehicle"),
|
||||
(CONTAINER, "Container"),
|
||||
]
|
||||
|
||||
LOOKUP = "lookup"
|
||||
FIELD = "field"
|
||||
KIND_CHOICES = [
|
||||
(LOOKUP, "Lookup Table"),
|
||||
(FIELD, "Custom Field"),
|
||||
]
|
||||
|
||||
code = models.CharField(max_length=50, unique=True)
|
||||
name = models.CharField(max_length=255)
|
||||
applies_to = models.CharField(
|
||||
applies_to = models.CharField(max_length=50, choices=APPLIES_TO_CHOICES)
|
||||
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=LOOKUP)
|
||||
display_field = models.CharField(
|
||||
max_length=50,
|
||||
choices=[("vehicle", "Vehicle"), ("container", "Container")]
|
||||
default="name",
|
||||
blank=True,
|
||||
help_text="Key of the NomenclatureField to use as dropdown label (for lookup kind)",
|
||||
)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort_order", "code"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.code})"
|
||||
|
||||
|
||||
class NomenclatureField(models.Model):
|
||||
TEXT = "text"
|
||||
@@ -23,7 +49,42 @@ class NomenclatureField(models.Model):
|
||||
]
|
||||
|
||||
nomenclature = models.ForeignKey(
|
||||
Nomenclature, on_delete=models.CASCADE
|
||||
Nomenclature,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="fields",
|
||||
)
|
||||
key = models.CharField(max_length=50)
|
||||
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
|
||||
label = models.CharField(max_length=100, blank=True)
|
||||
field_type = models.CharField(max_length=20, choices=FIELD_TYPES)
|
||||
required = models.BooleanField(default=False)
|
||||
choices = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='List of allowed values for choice type, e.g. ["Red","Green","Blue"]',
|
||||
)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("nomenclature", "key")]
|
||||
ordering = ["sort_order", "key"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.nomenclature.code}.{self.key} ({self.field_type})"
|
||||
|
||||
|
||||
class NomenclatureEntry(models.Model):
|
||||
nomenclature = models.ForeignKey(
|
||||
Nomenclature,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="entries",
|
||||
)
|
||||
data = models.JSONField(default=dict)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Nomenclature entries"
|
||||
ordering = ["id"]
|
||||
|
||||
def __str__(self):
|
||||
display_key = self.nomenclature.display_field or "name"
|
||||
return str(self.data.get(display_key, f"Entry #{self.pk}"))
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 4.2.8 on 2026-01-29 13:08
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("vehicles", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_gross",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_tare",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.2.8 on 2026-02-10 15:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"vehicles",
|
||||
"0002_vehicle_gross_vehicle_gross_date_vehicle_gross_user_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="driver_pid",
|
||||
field=models.CharField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="trailer1_number",
|
||||
field=models.CharField(blank=True, max_length=15, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="trailer2_number",
|
||||
field=models.CharField(blank=True, max_length=15, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,23 @@
|
||||
from typing import Required
|
||||
from django.db import models
|
||||
|
||||
from api.models import User
|
||||
|
||||
# Create your models here.
|
||||
class Vehicle(models.Model):
|
||||
vehicle_number = models.CharField(max_length=15, unique=True)
|
||||
trailer1_number = models.CharField(max_length=15, null=True, blank=True)
|
||||
trailer2_number = models.CharField(max_length=15, null=True, blank=True)
|
||||
driver_pid = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
tare = models.IntegerField(null=True, blank=True)
|
||||
tare_date = models.DateTimeField(null=True, blank=True)
|
||||
tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL)
|
||||
gross = models.IntegerField(null=True, blank=True)
|
||||
gross_date = models.DateTimeField(null=True, blank=True)
|
||||
gross_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross', on_delete=models.SET_NULL)
|
||||
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vehicle_number}"
|
||||
|
||||
@@ -7,6 +7,7 @@ import Main from './components/Main';
|
||||
import ReportEditor from './components/ReportEditor/ReportEditor';
|
||||
import './App.css';
|
||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
||||
|
||||
// function MainApp() {
|
||||
// const [selectedPort, setSelectedPort] = useState(null);
|
||||
@@ -55,10 +56,12 @@ function AppContent() {
|
||||
|
||||
return (
|
||||
<NomenclatureProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/report-editor" element={<ReportEditor />} />
|
||||
</Routes>
|
||||
<NomenclatureDataProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/report-editor" element={<ReportEditor />} />
|
||||
</Routes>
|
||||
</NomenclatureDataProvider>
|
||||
</NomenclatureProvider>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
||||
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
|
||||
import './Header.css';
|
||||
|
||||
function Header() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
|
||||
|
||||
const getInitials = (user) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
@@ -32,6 +34,13 @@ function Header() {
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => setShowNomenclatureManager(true)}
|
||||
title="Nomenclatures"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
@@ -54,6 +63,10 @@ function Header() {
|
||||
{showPasswordOverlay && (
|
||||
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
|
||||
)}
|
||||
|
||||
{showNomenclatureManager && (
|
||||
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - 74px);
|
||||
}
|
||||
|
||||
/* Left panel - vehicle list */
|
||||
.main-left {
|
||||
width: 260px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vehicle-list-header {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.vehicle-add-btn {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.vehicle-add-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.vehicle-search {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vehicle-search:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* New vehicle form */
|
||||
.vehicle-new-form {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f0f0ff;
|
||||
}
|
||||
|
||||
.vehicle-new-form input {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vehicle-new-form input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.vehicle-new-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vehicle-new-actions button {
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
color: #333;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.vehicle-new-actions button:first-child {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.vehicle-new-actions button:first-child:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
/* Vehicle list */
|
||||
.vehicle-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vehicle-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.vehicle-list-item:hover {
|
||||
background: #f0f0ff;
|
||||
}
|
||||
|
||||
.vehicle-list-item--active {
|
||||
background: #e8eaff;
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.vehicle-list-number {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.vehicle-list-weight {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.vehicle-list-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
.main-right {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.main-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.vehicle-detail {
|
||||
padding: 24px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.vehicle-detail-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vehicle-error {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fcc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Current reading */
|
||||
.reading-display {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f8fc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.reading-display label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.reading-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.reading-value--disconnected {
|
||||
color: #ccc;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Weighing cards */
|
||||
.weighing-section {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weight-card {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.weight-card--net {
|
||||
background: #f0fff0;
|
||||
border-color: #b2dfb2;
|
||||
}
|
||||
|
||||
.weight-card-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.weight-card--net .weight-card-header {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.weight-card-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.weight-card--net .weight-card-value {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.weight-card-meta {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.weight-set-btn {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.weight-set-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.weight-set-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Extra fields section */
|
||||
.extra-section {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.extra-section h3 {
|
||||
margin: 0 0 14px 0;
|
||||
font-size: 14px;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.extra-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.extra-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.extra-field label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.extra-field input[type="text"],
|
||||
.extra-field input[type="number"],
|
||||
.extra-field select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.extra-field input:focus,
|
||||
.extra-field select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.extra-field input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.extra-save-btn {
|
||||
padding: 8px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.extra-save-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.extra-save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.main-left {
|
||||
width: 100%;
|
||||
max-height: 250px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.weighing-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.extra-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import Header from './Header';
|
||||
import DataDisplay from './DataDisplay';
|
||||
import useSerialData from '../hooks/useSerialData';
|
||||
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
||||
|
||||
|
||||
export default function Main() {
|
||||
const { readings, isConnected, error } = useSerialData();
|
||||
const { vehicles } = useNomenclatures();
|
||||
console.log('Vehicles:', vehicles);
|
||||
return (
|
||||
<div className="main">
|
||||
<div>
|
||||
<div className="vehicles">
|
||||
{ vehicles.map(vehicle => {
|
||||
return (
|
||||
<div key={vehicle.id} className="vehicle-card">
|
||||
<h3>{vehicle.vehicle_number}</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vehicles-data">
|
||||
<div className="data-header">
|
||||
<h2>Vehicle Data</h2>
|
||||
</div>
|
||||
<div className="data-list">
|
||||
<Header />
|
||||
<DataDisplay readings={readings}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Header from './Header';
|
||||
import useSerialData from '../hooks/useSerialData';
|
||||
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
||||
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
|
||||
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
|
||||
import api from '../services/api';
|
||||
import './Main.css';
|
||||
|
||||
export default function Main() {
|
||||
const { readings, isConnected } = useSerialData();
|
||||
const { vehicles } = useNomenclatures();
|
||||
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [newVehicleNumber, setNewVehicleNumber] = useState('');
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Nomenclature data from context
|
||||
const { definitions } = useNomenclatureData();
|
||||
const vehicleNomenclatures = useMemo(
|
||||
() => Object.values(definitions)
|
||||
.filter(d => d.applies_to === 'vehicle')
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)),
|
||||
[definitions]
|
||||
);
|
||||
const [extraData, setExtraData] = useState({});
|
||||
|
||||
// Current reading from COM port
|
||||
// Data format: "Value: 36106.04" or just "36106.04"
|
||||
const currentReading = readings.length > 0 ? readings[0].data : null;
|
||||
const currentWeight = useMemo(() => {
|
||||
if (!currentReading) return null;
|
||||
// Extract numeric value - handle "Value: 123.45" or just "123.45"
|
||||
const match = currentReading.match(/[\d.]+/);
|
||||
if (match) {
|
||||
const val = parseFloat(match[0]);
|
||||
return isNaN(val) ? null : Math.round(val);
|
||||
}
|
||||
return null;
|
||||
}, [currentReading]);
|
||||
|
||||
// Selected vehicle object (refreshes when vehicles list updates via SSE)
|
||||
const selectedVehicle = useMemo(
|
||||
() => vehicles.find(v => v.id === selectedVehicleId) || null,
|
||||
[vehicles, selectedVehicleId]
|
||||
);
|
||||
|
||||
// Filtered vehicles
|
||||
const filteredVehicles = useMemo(() => {
|
||||
if (!searchQuery.trim()) return vehicles;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q));
|
||||
}, [vehicles, searchQuery]);
|
||||
|
||||
// When vehicle selection changes, load its extra data
|
||||
useEffect(() => {
|
||||
if (selectedVehicle?.extra?.data) {
|
||||
setExtraData({ ...selectedVehicle.extra.data });
|
||||
} else {
|
||||
setExtraData({});
|
||||
}
|
||||
}, [selectedVehicle]);
|
||||
|
||||
// Create new vehicle
|
||||
const handleCreateVehicle = async () => {
|
||||
if (!newVehicleNumber.trim()) return;
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.post('/api/vehicles/', { vehicle_number: newVehicleNumber.trim() });
|
||||
setSelectedVehicleId(res.data.id);
|
||||
setNewVehicleNumber('');
|
||||
setShowNewForm(false);
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.vehicle_number?.[0] || err.response?.data?.detail || 'Failed to create vehicle';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set tare
|
||||
const handleSetTare = async () => {
|
||||
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/vehicles/${selectedVehicle.id}/set-tare/`, { value: currentWeight });
|
||||
} catch {
|
||||
setError('Failed to set tare');
|
||||
}
|
||||
};
|
||||
|
||||
// Set gross
|
||||
const handleSetGross = async () => {
|
||||
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/vehicles/${selectedVehicle.id}/set-gross/`, { value: currentWeight });
|
||||
} catch {
|
||||
setError('Failed to set gross');
|
||||
}
|
||||
};
|
||||
|
||||
// Save extra data
|
||||
const handleSaveExtra = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
|
||||
extra: { data: extraData },
|
||||
});
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
setError(typeof data === 'string' ? data : JSON.stringify(data) || 'Failed to save');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Net weight
|
||||
const netWeight = (selectedVehicle?.tare != null && selectedVehicle?.gross != null)
|
||||
? selectedVehicle.gross - selectedVehicle.tare
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<div className="main">
|
||||
{/* Left panel - Vehicle list */}
|
||||
<div className="main-left">
|
||||
<div className="vehicle-list-header">
|
||||
<button
|
||||
className="vehicle-add-btn"
|
||||
onClick={() => { setShowNewForm(true); setError(''); }}
|
||||
>
|
||||
+ New Vehicle
|
||||
</button>
|
||||
<input
|
||||
className="vehicle-search"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showNewForm && (
|
||||
<div className="vehicle-new-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vehicle number"
|
||||
value={newVehicleNumber}
|
||||
onChange={e => setNewVehicleNumber(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateVehicle()}
|
||||
disabled={isSaving}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="vehicle-new-actions">
|
||||
<button onClick={handleCreateVehicle} disabled={isSaving}>
|
||||
{isSaving ? '...' : 'Create'}
|
||||
</button>
|
||||
<button onClick={() => { setShowNewForm(false); setNewVehicleNumber(''); setError(''); }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vehicle-list">
|
||||
{filteredVehicles.map(v => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vehicle-list-item ${selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''}`}
|
||||
onClick={() => { setSelectedVehicleId(v.id); setError(''); }}
|
||||
>
|
||||
<span className="vehicle-list-number">{v.vehicle_number}</span>
|
||||
{v.tare != null && (
|
||||
<span className="vehicle-list-weight">{v.tare} kg</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredVehicles.length === 0 && (
|
||||
<div className="vehicle-list-empty">No vehicles found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Vehicle detail */}
|
||||
<div className="main-right">
|
||||
{!selectedVehicle ? (
|
||||
<div className="main-placeholder">
|
||||
Select a vehicle or create a new one
|
||||
</div>
|
||||
) : (
|
||||
<div className="vehicle-detail">
|
||||
{error && <div className="vehicle-error">{error}</div>}
|
||||
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
|
||||
{/* Current reading */}
|
||||
<div className="reading-display">
|
||||
<label>Current Reading</label>
|
||||
<div className={`reading-value ${isConnected ? '' : 'reading-value--disconnected'}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weighing section */}
|
||||
<div className="weighing-section">
|
||||
{/* Tare */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'}
|
||||
</div>
|
||||
{selectedVehicle.tare_date && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.tare_date)}
|
||||
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleSetTare}
|
||||
disabled={currentWeight === null || isNaN(currentWeight)}
|
||||
>
|
||||
Set Tare
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gross */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'}
|
||||
</div>
|
||||
{selectedVehicle.gross_date && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.gross_date)}
|
||||
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleSetGross}
|
||||
disabled={currentWeight === null || isNaN(currentWeight)}
|
||||
>
|
||||
Set Gross
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Net */}
|
||||
{netWeight !== null && (
|
||||
<div className="weight-card weight-card--net">
|
||||
<div className="weight-card-header">Net</div>
|
||||
<div className="weight-card-value">{netWeight} kg</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extra fields from nomenclatures */}
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<h3>Additional Data</h3>
|
||||
<div className="extra-fields">
|
||||
{vehicleNomenclatures.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
<NomenclatureDropdown
|
||||
nomenclatureCode={nom.code}
|
||||
value={extraData[nom.code]}
|
||||
onChange={val => setExtraData(prev => ({
|
||||
...prev,
|
||||
[nom.code]: val,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="extra-save-btn"
|
||||
onClick={handleSaveExtra}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Extra Data'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
/* Overlay backdrop */
|
||||
.nm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
animation: nmFadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
@keyframes nmFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.nm-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: nmSlideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nmSlideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.nm-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-header h2 {
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.nm-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-close-button:hover {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Body - two panels */
|
||||
.nm-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Left panel */
|
||||
.nm-list-panel {
|
||||
width: 250px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-add-btn {
|
||||
margin: 12px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.nm-add-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nm-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nm-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.nm-list-item:hover {
|
||||
background: #f5f5ff;
|
||||
}
|
||||
|
||||
.nm-list-item--active {
|
||||
background: #eef0ff;
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.nm-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nm-list-item-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nm-list-item-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nm-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-edit-btn:hover {
|
||||
color: #667eea;
|
||||
background: #f0f0ff;
|
||||
}
|
||||
|
||||
.nm-loading {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nm-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
.nm-form-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nm-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 15px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nm-form {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.nm-form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nm-error {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fcc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Form grid */
|
||||
.nm-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nm-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nm-form-group label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nm-form-group input[type="text"],
|
||||
.nm-form-group input[type="number"],
|
||||
.nm-form-group select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.nm-form-group input:focus,
|
||||
.nm-form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.nm-form-group input:disabled,
|
||||
.nm-form-group select:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.nm-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nm-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.nm-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nm-section-add {
|
||||
background: none;
|
||||
border: 1px solid #667eea;
|
||||
color: #667eea;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-section-add:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nm-section-add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Fields table */
|
||||
.nm-fields-table {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nm-fields-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #f8f8fc;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nm-fields-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nm-fields-row input[type="text"] {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nm-fields-row input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.nm-fields-row select {
|
||||
padding: 5px 4px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nm-fields-row select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.nm-fields-row input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.nm-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.nm-remove-btn:hover:not(:disabled) {
|
||||
color: #c33;
|
||||
background: #fee;
|
||||
}
|
||||
|
||||
.nm-remove-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Entries */
|
||||
.nm-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nm-entry {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nm-entry--inactive {
|
||||
opacity: 0.5;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.nm-entry-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nm-entry-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nm-entry-field label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nm-entry-field input[type="text"],
|
||||
.nm-entry-field input[type="number"],
|
||||
.nm-entry-field select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nm-entry-field input:focus,
|
||||
.nm-entry-field select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.nm-entry-field input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nm-entry-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-entry-save {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nm-entry-save:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.nm-entry-toggle {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nm-entry-toggle:hover {
|
||||
background: #c8e6c9;
|
||||
}
|
||||
|
||||
.nm-entry-toggle--inactive {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.nm-entry-toggle--inactive:hover {
|
||||
background: #ffe0b2;
|
||||
}
|
||||
|
||||
/* Form actions */
|
||||
.nm-form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.nm-form-actions-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nm-cancel-btn {
|
||||
padding: 8px 20px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
color: #666;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-cancel-btn:hover:not(:disabled) {
|
||||
border-color: #999;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nm-save-btn {
|
||||
padding: 8px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.nm-save-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nm-save-btn:disabled,
|
||||
.nm-cancel-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nm-delete-btn {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border: 1px solid #e57373;
|
||||
color: #c33;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-delete-btn:hover:not(:disabled) {
|
||||
background: #fee;
|
||||
}
|
||||
|
||||
.nm-delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.nm-content {
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.nm-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nm-list-panel {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.nm-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../../services/api';
|
||||
import './NomenclatureManager.css';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
code: '',
|
||||
name: '',
|
||||
applies_to: 'vehicle',
|
||||
kind: 'lookup',
|
||||
display_field: 'name',
|
||||
sort_order: 0,
|
||||
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
|
||||
};
|
||||
|
||||
function NomenclatureManager({ onClose }) {
|
||||
const [nomenclatures, setNomenclatures] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [form, setForm] = useState(null); // null = nothing selected
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Load nomenclature list
|
||||
const loadNomenclatures = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.get('/api/nomenclatures/');
|
||||
const data = res.data.results || res.data;
|
||||
setNomenclatures(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
setError('Failed to load nomenclatures');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNomenclatures();
|
||||
}, [loadNomenclatures]);
|
||||
|
||||
// Select a nomenclature for editing
|
||||
const handleSelect = (nom) => {
|
||||
setSelectedId(nom.id);
|
||||
setError('');
|
||||
setForm({
|
||||
code: nom.code,
|
||||
name: nom.name,
|
||||
applies_to: nom.applies_to,
|
||||
kind: nom.kind,
|
||||
display_field: nom.display_field || 'name',
|
||||
sort_order: nom.sort_order || 0,
|
||||
fields: (nom.fields || []).map(f => ({
|
||||
key: f.key,
|
||||
label: f.label || '',
|
||||
field_type: f.field_type,
|
||||
required: f.required || false,
|
||||
choices: f.choices || null,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
// Start adding new nomenclature
|
||||
const handleAdd = () => {
|
||||
setSelectedId(null);
|
||||
setError('');
|
||||
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }] });
|
||||
};
|
||||
|
||||
// Cancel editing
|
||||
const handleCancel = () => {
|
||||
setForm(null);
|
||||
setSelectedId(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
// Save nomenclature (create or update)
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
|
||||
const payload = {
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
applies_to: form.applies_to,
|
||||
kind: form.kind,
|
||||
display_field: form.display_field,
|
||||
sort_order: form.sort_order,
|
||||
fields: form.fields.map(f => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.field_type,
|
||||
required: f.required,
|
||||
choices: f.field_type === 'choice' ? f.choices : null,
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedId) {
|
||||
await api.put(`/api/nomenclatures/${selectedId}/`, payload);
|
||||
} else {
|
||||
const res = await api.post('/api/nomenclatures/', payload);
|
||||
setSelectedId(res.data.id);
|
||||
}
|
||||
await loadNomenclatures();
|
||||
setError('');
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
if (typeof data === 'string') {
|
||||
setError(data);
|
||||
} else if (data) {
|
||||
const messages = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (Array.isArray(val)) messages.push(`${key}: ${val.join(', ')}`);
|
||||
else if (typeof val === 'string') messages.push(`${key}: ${val}`);
|
||||
else messages.push(`${key}: ${JSON.stringify(val)}`);
|
||||
}
|
||||
setError(messages.join('; ') || 'Failed to save');
|
||||
} else {
|
||||
setError('Failed to save nomenclature');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete nomenclature
|
||||
const handleDelete = async () => {
|
||||
if (!selectedId) return;
|
||||
if (!window.confirm('Delete this nomenclature and all its entries?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/nomenclatures/${selectedId}/`);
|
||||
setForm(null);
|
||||
setSelectedId(null);
|
||||
await loadNomenclatures();
|
||||
} catch {
|
||||
setError('Failed to delete nomenclature');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Field management ---
|
||||
const addField = () => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
fields: [...prev.fields, { key: '', label: '', field_type: 'text', required: false, choices: null }],
|
||||
}));
|
||||
};
|
||||
|
||||
const updateField = (index, key, value) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
fields: prev.fields.map((f, i) => i === index ? { ...f, [key]: value } : f),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeField = (index) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
fields: prev.fields.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target.className === 'nm-overlay') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nm-overlay" onClick={handleOverlayClick}>
|
||||
<div className="nm-content">
|
||||
{/* Header */}
|
||||
<div className="nm-header">
|
||||
<h2>Nomenclatures</h2>
|
||||
<button className="nm-close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="nm-body">
|
||||
{/* Left panel - List */}
|
||||
<div className="nm-list-panel">
|
||||
<button className="nm-add-btn" onClick={handleAdd}>+ Add New</button>
|
||||
|
||||
{isLoading && <div className="nm-loading">Loading...</div>}
|
||||
|
||||
<div className="nm-list">
|
||||
{nomenclatures.map(nom => (
|
||||
<div
|
||||
key={nom.id}
|
||||
className={`nm-list-item ${selectedId === nom.id ? 'nm-list-item--active' : ''}`}
|
||||
onClick={() => handleSelect(nom)}
|
||||
>
|
||||
<div className="nm-list-item-info">
|
||||
<span className="nm-list-item-name">{nom.name}</span>
|
||||
<span className="nm-list-item-meta">
|
||||
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="nm-edit-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(nom); }}
|
||||
title="Edit"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && nomenclatures.length === 0 && (
|
||||
<div className="nm-empty">No nomenclatures yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - Form */}
|
||||
<div className="nm-form-panel">
|
||||
{!form ? (
|
||||
<div className="nm-placeholder">
|
||||
Select a nomenclature to edit or click "+ Add New"
|
||||
</div>
|
||||
) : (
|
||||
<div className="nm-form">
|
||||
{error && <div className="nm-error">{error}</div>}
|
||||
|
||||
<div className="nm-form-title">
|
||||
{selectedId ? 'Edit Nomenclature' : 'New Nomenclature'}
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="nm-form-grid">
|
||||
<div className="nm-form-group">
|
||||
<label>Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.code}
|
||||
onChange={e => setForm(prev => ({ ...prev, code: e.target.value }))}
|
||||
placeholder="e.g. cargo_type"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nm-form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Cargo Type"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nm-form-group">
|
||||
<label>Applies to</label>
|
||||
<select
|
||||
value={form.applies_to}
|
||||
onChange={e => setForm(prev => ({ ...prev, applies_to: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="vehicle">Vehicle</option>
|
||||
<option value="container">Container</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="nm-form-group">
|
||||
<label>Kind</label>
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={e => setForm(prev => ({ ...prev, kind: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="lookup">Lookup Table</option>
|
||||
<option value="field">Custom Field</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{form.kind === 'lookup' && (
|
||||
<div className="nm-form-group">
|
||||
<label>Display Field</label>
|
||||
<select
|
||||
value={form.display_field}
|
||||
onChange={e => setForm(prev => ({ ...prev, display_field: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{form.fields.filter(f => f.key).map(f => (
|
||||
<option key={f.key} value={f.key}>{f.key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fields section */}
|
||||
<div className="nm-section">
|
||||
<div className="nm-section-header">
|
||||
<h3>Fields</h3>
|
||||
<button className="nm-section-add" onClick={addField} disabled={isSaving}>
|
||||
+ Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="nm-fields-table">
|
||||
<div className="nm-fields-header">
|
||||
<span>Key</span>
|
||||
<span>Label</span>
|
||||
<span>Type</span>
|
||||
<span>Req</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{form.fields.map((field, idx) => (
|
||||
<div key={idx} className="nm-fields-row">
|
||||
<input
|
||||
type="text"
|
||||
value={field.key}
|
||||
onChange={e => updateField(idx, 'key', e.target.value)}
|
||||
placeholder="key"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={e => updateField(idx, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<select
|
||||
value={field.field_type}
|
||||
onChange={e => updateField(idx, 'field_type', e.target.value)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="bool">Boolean</option>
|
||||
<option value="choice">Choice</option>
|
||||
</select>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={e => updateField(idx, 'required', e.target.checked)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<button
|
||||
className="nm-remove-btn"
|
||||
onClick={() => removeField(idx)}
|
||||
disabled={isSaving || form.fields.length <= 1}
|
||||
title="Remove field"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="nm-form-actions">
|
||||
{selectedId && (
|
||||
<button
|
||||
className="nm-delete-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<div className="nm-form-actions-right">
|
||||
<button
|
||||
className="nm-cancel-btn"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="nm-save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NomenclatureManager;
|
||||
@@ -0,0 +1,80 @@
|
||||
.nui-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 13000;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: nuiFormFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nuiFormFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.nui-form-modal {
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: nuiFormSlideUp 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nuiFormSlideUp {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.nui-form-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-form-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nui-form-modal-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nui-form-modal-close:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nui-form-modal-content {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import './FormOverlay.css';
|
||||
|
||||
export default function FormOverlay({ isOpen, onClose, title, children }) {
|
||||
const overlayRef = useRef(null);
|
||||
const mouseDownTargetRef = useRef(null);
|
||||
|
||||
// Escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Smart click handling: only close if mousedown AND mouseup both on backdrop
|
||||
// This prevents closing when user selects text and drags to backdrop
|
||||
const handleMouseDown = (e) => {
|
||||
mouseDownTargetRef.current = e.target;
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (mouseDownTargetRef.current === overlayRef.current && e.target === overlayRef.current) {
|
||||
onClose();
|
||||
}
|
||||
mouseDownTargetRef.current = null;
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="nui-form-overlay"
|
||||
ref={overlayRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<div className="nui-form-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="nui-form-modal-header">
|
||||
<h3>{title}</h3>
|
||||
<button
|
||||
className="nui-form-modal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="nui-form-modal-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
.nui-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nui-dropdown-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.nui-dropdown-label--checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nui-dropdown-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nui-dropdown-select {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
transition: border-color 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nui-dropdown-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.nui-dropdown-select:disabled {
|
||||
background-color: #f0f0f0;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.nui-dropdown-input {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nui-dropdown-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.nui-dropdown-input:disabled {
|
||||
background-color: #f0f0f0;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Hide number spinner */
|
||||
.nui-dropdown-input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.nui-dropdown-input[type="number"]::-webkit-outer-spin-button,
|
||||
.nui-dropdown-input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nui-dropdown-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nui-dropdown-manage-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nui-dropdown-manage-btn:hover:not(:disabled) {
|
||||
border-color: #667eea;
|
||||
color: #667eea;
|
||||
background-color: #f0f0ff;
|
||||
}
|
||||
|
||||
.nui-dropdown-manage-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
||||
import NomenclatureManagementOverlay from './NomenclatureManagementOverlay';
|
||||
import './NomenclatureDropdown.css';
|
||||
|
||||
export default function NomenclatureDropdown({
|
||||
nomenclatureCode,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}) {
|
||||
const { definitions, getActiveEntries, isLoading } = useNomenclatureData();
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
|
||||
const nomenclature = definitions[nomenclatureCode];
|
||||
const activeEntries = useMemo(
|
||||
() => getActiveEntries(nomenclatureCode),
|
||||
[getActiveEntries, nomenclatureCode]
|
||||
);
|
||||
|
||||
if (!nomenclature) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="nui-dropdown">
|
||||
{label && <label className="nui-dropdown-label">{label}</label>}
|
||||
<select disabled className="nui-dropdown-select">
|
||||
<option>Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayLabel = label || nomenclature.name;
|
||||
|
||||
// LOOKUP type: select dropdown + manage button
|
||||
if (nomenclature.kind === 'lookup') {
|
||||
const handleSelectChange = (e) => {
|
||||
const val = e.target.value;
|
||||
onChange(val ? parseInt(val, 10) : undefined);
|
||||
};
|
||||
|
||||
const handleOverlaySelect = (entryId) => {
|
||||
onChange(entryId);
|
||||
setShowOverlay(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nui-dropdown">
|
||||
<label className="nui-dropdown-label">{displayLabel}</label>
|
||||
<div className="nui-dropdown-row">
|
||||
<select
|
||||
className="nui-dropdown-select"
|
||||
value={value || ''}
|
||||
onChange={handleSelectChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="">-- Select --</option>
|
||||
{activeEntries.map(entry => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.display_value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="nui-dropdown-manage-btn"
|
||||
onClick={() => setShowOverlay(true)}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
title={`Manage ${displayLabel}`}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NomenclatureManagementOverlay
|
||||
nomenclatureCode={nomenclatureCode}
|
||||
isOpen={showOverlay}
|
||||
onClose={() => setShowOverlay(false)}
|
||||
mode="select"
|
||||
initialSelection={value || null}
|
||||
onSelect={handleOverlaySelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FIELD type: render appropriate input
|
||||
const fieldDef = nomenclature.fields?.[0];
|
||||
if (!fieldDef) return null;
|
||||
|
||||
if (fieldDef.field_type === 'bool') {
|
||||
return (
|
||||
<div className="nui-dropdown">
|
||||
<label className="nui-dropdown-label nui-dropdown-label--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value || false}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="nui-dropdown-checkbox"
|
||||
/>
|
||||
{displayLabel}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldDef.field_type === 'number') {
|
||||
return (
|
||||
<div className="nui-dropdown">
|
||||
<label className="nui-dropdown-label">{displayLabel}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="nui-dropdown-input"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldDef.field_type === 'choice' && fieldDef.choices) {
|
||||
return (
|
||||
<div className="nui-dropdown">
|
||||
<label className="nui-dropdown-label">{displayLabel}</label>
|
||||
<select
|
||||
className="nui-dropdown-select"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<option value="">-- Select --</option>
|
||||
{fieldDef.choices.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: text input
|
||||
return (
|
||||
<div className="nui-dropdown">
|
||||
<label className="nui-dropdown-label">{displayLabel}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="nui-dropdown-input"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.nui-entry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nui-entry-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nui-entry-form-field > label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nui-entry-form-field input[type="text"],
|
||||
.nui-entry-form-field input[type="number"],
|
||||
.nui-entry-form-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nui-entry-form-field input[type="text"]:focus,
|
||||
.nui-entry-form-field input[type="number"]:focus,
|
||||
.nui-entry-form-field select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.nui-entry-form-field input[type="text"]:disabled,
|
||||
.nui-entry-form-field input[type="number"]:disabled,
|
||||
.nui-entry-form-field select:disabled {
|
||||
background-color: #f0f0f0;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Hide number spinner */
|
||||
.nui-entry-form-field input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.nui-entry-form-field input[type="number"]::-webkit-outer-spin-button,
|
||||
.nui-entry-form-field input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.nui-entry-form-input-error {
|
||||
border-color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.nui-entry-form-error {
|
||||
font-size: 0.8rem;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* Checkbox field */
|
||||
.nui-entry-form-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: normal !important;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nui-entry-form-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.nui-entry-form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.nui-entry-form-cancel {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.nui-entry-form-cancel:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.nui-entry-form-cancel:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.nui-entry-form-save {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.nui-entry-form-save:hover:not(:disabled) {
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.nui-entry-form-save:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.nui-entry-form-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './NomenclatureEntryForm.css';
|
||||
|
||||
// Auto-select text on focus (PF99 pattern)
|
||||
const handleAutoSelect = (e) => e.target.select();
|
||||
const preventDrag = (e) => e.preventDefault();
|
||||
|
||||
export default function NomenclatureEntryForm({
|
||||
fields = [],
|
||||
mode = 'add',
|
||||
initialData = null,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving = false,
|
||||
}) {
|
||||
const firstInputRef = useRef(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// Initialize form data
|
||||
useEffect(() => {
|
||||
const data = {};
|
||||
const sortedFields = [...fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
|
||||
for (const field of sortedFields) {
|
||||
if (mode === 'edit' && initialData) {
|
||||
data[field.key] = initialData[field.key] ?? getDefaultValue(field.field_type);
|
||||
} else {
|
||||
data[field.key] = getDefaultValue(field.field_type);
|
||||
}
|
||||
}
|
||||
setFormData(data);
|
||||
setErrors({});
|
||||
|
||||
// Auto-focus first input
|
||||
setTimeout(() => firstInputRef.current?.focus(), 50);
|
||||
}, [fields, mode, initialData]);
|
||||
|
||||
const getDefaultValue = (fieldType) => {
|
||||
switch (fieldType) {
|
||||
case 'bool': return false;
|
||||
case 'number': return '';
|
||||
case 'choice': return '';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
// Clear error on change
|
||||
if (errors[key]) {
|
||||
setErrors(prev => ({ ...prev, [key]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
const sortedFields = [...fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
|
||||
for (const field of sortedFields) {
|
||||
if (field.required) {
|
||||
const val = formData[field.key];
|
||||
if (field.field_type === 'bool') continue; // booleans are always valid
|
||||
if (val === '' || val === null || val === undefined) {
|
||||
newErrors[field.key] = `${field.label || field.key} is required`;
|
||||
}
|
||||
}
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
// Convert types before saving
|
||||
const cleanData = {};
|
||||
for (const field of fields) {
|
||||
let val = formData[field.key];
|
||||
if (field.field_type === 'number' && val !== '' && val !== undefined) {
|
||||
val = Number(val);
|
||||
}
|
||||
cleanData[field.key] = val;
|
||||
}
|
||||
onSave(cleanData);
|
||||
};
|
||||
|
||||
const sortedFields = [...fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
|
||||
return (
|
||||
<form className="nui-entry-form" onSubmit={handleSubmit}>
|
||||
{sortedFields.map((field, idx) => (
|
||||
<div key={field.key} className="nui-entry-form-field">
|
||||
<label>{field.label || field.key}{field.required ? ' *' : ''}</label>
|
||||
|
||||
{field.field_type === 'bool' ? (
|
||||
<label className="nui-entry-form-checkbox-label">
|
||||
<input
|
||||
ref={idx === 0 ? firstInputRef : undefined}
|
||||
type="checkbox"
|
||||
checked={formData[field.key] || false}
|
||||
onChange={(e) => handleChange(field.key, e.target.checked)}
|
||||
disabled={isSaving}
|
||||
className="nui-entry-form-checkbox"
|
||||
/>
|
||||
<span>{formData[field.key] ? 'Yes' : 'No'}</span>
|
||||
</label>
|
||||
) : field.field_type === 'number' ? (
|
||||
<input
|
||||
ref={idx === 0 ? firstInputRef : undefined}
|
||||
type="number"
|
||||
value={formData[field.key] ?? ''}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onFocus={handleAutoSelect}
|
||||
onDragStart={preventDrag}
|
||||
disabled={isSaving}
|
||||
className={errors[field.key] ? 'nui-entry-form-input-error' : ''}
|
||||
/>
|
||||
) : field.field_type === 'choice' && field.choices ? (
|
||||
<select
|
||||
ref={idx === 0 ? firstInputRef : undefined}
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
disabled={isSaving}
|
||||
className={errors[field.key] ? 'nui-entry-form-input-error' : ''}
|
||||
>
|
||||
<option value="">-- Select --</option>
|
||||
{field.choices.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
ref={idx === 0 ? firstInputRef : undefined}
|
||||
type="text"
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onFocus={handleAutoSelect}
|
||||
onDragStart={preventDrag}
|
||||
disabled={isSaving}
|
||||
className={errors[field.key] ? 'nui-entry-form-input-error' : ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errors[field.key] && (
|
||||
<span className="nui-entry-form-error">{errors[field.key]}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="nui-entry-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="nui-entry-form-cancel"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="nui-entry-form-save"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : mode === 'edit' ? 'Update' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
.nui-mgmt-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 12000;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: nuiMgmtFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nuiMgmtFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.nui-mgmt-content {
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: nuiMgmtSlideUp 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nuiMgmtSlideUp {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.nui-mgmt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-mgmt-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nui-mgmt-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nui-mgmt-close:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.nui-mgmt-toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-mgmt-search {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-search:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.nui-mgmt-add-btn {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nui-mgmt-add-btn:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.nui-mgmt-error {
|
||||
margin: 0.5rem 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Table container */
|
||||
.nui-mgmt-table-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.nui-mgmt-table-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.nui-mgmt-table-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nui-mgmt-table-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nui-mgmt-table-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.nui-mgmt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.nui-mgmt-table thead {
|
||||
background-color: #f8f9fa;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nui-mgmt-table th {
|
||||
padding: 0.6rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.nui-mgmt-th-id {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nui-mgmt-th-status {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nui-mgmt-th-actions {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nui-mgmt-table td {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nui-mgmt-td-id {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.nui-mgmt-td-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nui-mgmt-td-actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Row states */
|
||||
.nui-mgmt-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.nui-mgmt-row:hover {
|
||||
background-color: #f0f0ff;
|
||||
}
|
||||
|
||||
.nui-mgmt-row--selected {
|
||||
background-color: #e8eaff;
|
||||
}
|
||||
|
||||
.nui-mgmt-row--selected:hover {
|
||||
background-color: #dcdeff;
|
||||
}
|
||||
|
||||
.nui-mgmt-row--inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.nui-mgmt-row--inactive td {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.nui-mgmt-row--inactive .nui-mgmt-td-id,
|
||||
.nui-mgmt-row--inactive .nui-mgmt-td-status,
|
||||
.nui-mgmt-row--inactive .nui-mgmt-td-actions {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Active badge */
|
||||
.nui-mgmt-active-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--active {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--active:hover {
|
||||
background-color: #c3e6cb;
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--inactive {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--inactive:hover {
|
||||
background-color: #f5c6cb;
|
||||
}
|
||||
|
||||
/* Edit button in row */
|
||||
.nui-mgmt-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.nui-mgmt-edit-btn:hover {
|
||||
background-color: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.nui-mgmt-empty {
|
||||
text-align: center;
|
||||
padding: 2rem !important;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.nui-mgmt-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0 0 8px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-info {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid #667eea;
|
||||
color: #667eea;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-edit-btn:hover {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nui-mgmt-delete-btn {
|
||||
background: none;
|
||||
border: 1px solid #dc3545;
|
||||
color: #dc3545;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-delete-btn:hover {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nui-mgmt-select-btn {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
padding: 0.4rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-select-btn:hover:not(:disabled) {
|
||||
background-color: #5568d3;
|
||||
}
|
||||
|
||||
.nui-mgmt-select-btn:disabled {
|
||||
background-color: #6c757d;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNomenclatureData } from '../../contexts/NomenclatureDataContext';
|
||||
import FormOverlay from './FormOverlay';
|
||||
import NomenclatureEntryForm from './NomenclatureEntryForm';
|
||||
import './NomenclatureManagementOverlay.css';
|
||||
|
||||
export default function NomenclatureManagementOverlay({
|
||||
nomenclatureCode,
|
||||
isOpen,
|
||||
onClose,
|
||||
mode = 'manage', // 'manage' or 'select'
|
||||
initialSelection = null, // entry ID to pre-select
|
||||
onSelect, // called in 'select' mode when user picks entry
|
||||
}) {
|
||||
const {
|
||||
definitions, entries,
|
||||
createEntry, updateEntry, deleteEntry, toggleEntryActive,
|
||||
} = useNomenclatureData();
|
||||
|
||||
const nomenclature = definitions[nomenclatureCode];
|
||||
const allEntries = entries[nomenclatureCode] || [];
|
||||
|
||||
const [selectedId, setSelectedId] = useState(initialSelection);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formMode, setFormMode] = useState('add');
|
||||
const [editData, setEditData] = useState(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Track items before form opens to detect new additions
|
||||
const itemsBeforeFormRef = useRef(new Set());
|
||||
const selectedRowRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const overlayRef = useRef(null);
|
||||
|
||||
// Reset state when overlay opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedId(initialSelection);
|
||||
setSearchTerm('');
|
||||
setShowForm(false);
|
||||
setError('');
|
||||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen, initialSelection]);
|
||||
|
||||
// Auto-scroll to selected item
|
||||
useEffect(() => {
|
||||
if (selectedRowRef.current) {
|
||||
selectedRowRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, [selectedId]);
|
||||
|
||||
// Auto-select newly added items after form closes
|
||||
useEffect(() => {
|
||||
if (!showForm && itemsBeforeFormRef.current.size > 0) {
|
||||
const currentIds = new Set(allEntries.map(e => e.id));
|
||||
for (const id of currentIds) {
|
||||
if (!itemsBeforeFormRef.current.has(id)) {
|
||||
setSelectedId(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
itemsBeforeFormRef.current = new Set();
|
||||
}
|
||||
}, [showForm, allEntries]);
|
||||
|
||||
// Fields sorted by sort_order
|
||||
const sortedFields = useMemo(() => {
|
||||
if (!nomenclature?.fields) return [];
|
||||
return [...nomenclature.fields].sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
}, [nomenclature]);
|
||||
|
||||
// Search fields: all text-type field keys
|
||||
const searchFieldKeys = useMemo(() => {
|
||||
return sortedFields
|
||||
.filter(f => f.field_type === 'text' || f.field_type === 'choice')
|
||||
.map(f => f.key);
|
||||
}, [sortedFields]);
|
||||
|
||||
// Filtered entries by search
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!searchTerm.trim()) return allEntries;
|
||||
const q = searchTerm.toLowerCase();
|
||||
return allEntries.filter(entry => {
|
||||
return searchFieldKeys.some(key => {
|
||||
const val = entry.data?.[key];
|
||||
return val && String(val).toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
}, [allEntries, searchTerm, searchFieldKeys]);
|
||||
|
||||
// Selected entry object
|
||||
const selectedEntry = useMemo(
|
||||
() => allEntries.find(e => e.id === selectedId) || null,
|
||||
[allEntries, selectedId]
|
||||
);
|
||||
|
||||
// Escape key to close (only when form is not open)
|
||||
useEffect(() => {
|
||||
if (!isOpen || showForm) return;
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, showForm, onClose]);
|
||||
|
||||
// Prevent body scroll
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleRowClick = useCallback((entry) => {
|
||||
setSelectedId(entry.id);
|
||||
}, []);
|
||||
|
||||
const handleRowDoubleClick = useCallback((entry) => {
|
||||
if (mode === 'select' && onSelect) {
|
||||
onSelect(entry.id);
|
||||
onClose();
|
||||
}
|
||||
}, [mode, onSelect, onClose]);
|
||||
|
||||
const handleAddClick = () => {
|
||||
// Track current items to detect new one after save
|
||||
itemsBeforeFormRef.current = new Set(allEntries.map(e => e.id));
|
||||
setFormMode('add');
|
||||
setEditData(null);
|
||||
setShowForm(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (!selectedEntry) return;
|
||||
setFormMode('edit');
|
||||
setEditData(selectedEntry.data);
|
||||
setShowForm(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleFormSave = async (formData) => {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
if (formMode === 'add') {
|
||||
await createEntry(nomenclature.id, formData);
|
||||
} else {
|
||||
await updateEntry(selectedId, { data: formData });
|
||||
}
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
if (typeof data === 'string') {
|
||||
setError(data);
|
||||
} else if (data) {
|
||||
const messages = [];
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
if (Array.isArray(val)) messages.push(`${key}: ${val.join(', ')}`);
|
||||
else if (typeof val === 'string') messages.push(`${key}: ${val}`);
|
||||
else messages.push(`${key}: ${JSON.stringify(val)}`);
|
||||
}
|
||||
setError(messages.join('; ') || 'Failed to save');
|
||||
} else {
|
||||
setError('Failed to save entry');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = async () => {
|
||||
if (!selectedEntry) return;
|
||||
if (!window.confirm('Delete this entry?')) return;
|
||||
setError('');
|
||||
try {
|
||||
await deleteEntry(selectedId);
|
||||
setSelectedId(null);
|
||||
} catch {
|
||||
setError('Failed to delete entry');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!selectedEntry) return;
|
||||
setError('');
|
||||
try {
|
||||
await toggleEntryActive(selectedId, selectedEntry.is_active);
|
||||
} catch {
|
||||
setError('Failed to update entry');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectClick = () => {
|
||||
if (!selectedEntry || !onSelect) return;
|
||||
onSelect(selectedEntry.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Smart backdrop click: only close if mousedown+mouseup both on backdrop
|
||||
const mouseDownTargetRef = useRef(null);
|
||||
const handleMouseDown = (e) => { mouseDownTargetRef.current = e.target; };
|
||||
const handleMouseUp = (e) => {
|
||||
if (mouseDownTargetRef.current === overlayRef.current && e.target === overlayRef.current) {
|
||||
onClose();
|
||||
}
|
||||
mouseDownTargetRef.current = null;
|
||||
};
|
||||
|
||||
if (!isOpen || !nomenclature) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="nui-mgmt-overlay"
|
||||
ref={overlayRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<div className="nui-mgmt-content" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="nui-mgmt-header">
|
||||
<h2>{nomenclature.name}</h2>
|
||||
<button className="nui-mgmt-close" onClick={onClose} type="button">×</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="nui-mgmt-toolbar">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="nui-mgmt-search"
|
||||
placeholder={`Search ${nomenclature.name}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<button className="nui-mgmt-add-btn" onClick={handleAddClick} type="button">
|
||||
+ Add New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="nui-mgmt-error">{error}</div>}
|
||||
|
||||
{/* Table */}
|
||||
<div className="nui-mgmt-table-container">
|
||||
<table className="nui-mgmt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nui-mgmt-th-id">ID</th>
|
||||
{sortedFields.map(f => (
|
||||
<th key={f.key}>{f.label || f.key}</th>
|
||||
))}
|
||||
<th className="nui-mgmt-th-status">Active</th>
|
||||
<th className="nui-mgmt-th-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={sortedFields.length + 3} className="nui-mgmt-empty">
|
||||
{searchTerm ? 'No entries match your search' : 'No entries yet. Click "+ Add New" to create one.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredEntries.map(entry => (
|
||||
<tr
|
||||
key={entry.id}
|
||||
ref={entry.id === selectedId ? selectedRowRef : undefined}
|
||||
className={`nui-mgmt-row ${selectedId === entry.id ? 'nui-mgmt-row--selected' : ''} ${!entry.is_active ? 'nui-mgmt-row--inactive' : ''}`}
|
||||
onClick={() => handleRowClick(entry)}
|
||||
onDoubleClick={() => handleRowDoubleClick(entry)}
|
||||
>
|
||||
<td className="nui-mgmt-td-id">{entry.id}</td>
|
||||
{sortedFields.map(f => (
|
||||
<td key={f.key}>
|
||||
{f.field_type === 'bool'
|
||||
? (entry.data?.[f.key] ? 'Yes' : 'No')
|
||||
: (entry.data?.[f.key] ?? '')
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
<td className="nui-mgmt-td-status">
|
||||
<span
|
||||
className={`nui-mgmt-active-badge ${entry.is_active ? 'nui-mgmt-active-badge--active' : 'nui-mgmt-active-badge--inactive'}`}
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedId(entry.id); toggleEntryActive(entry.id, entry.is_active); }}
|
||||
title={entry.is_active ? 'Click to deactivate' : 'Click to activate'}
|
||||
>
|
||||
{entry.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="nui-mgmt-td-actions">
|
||||
<button
|
||||
className="nui-mgmt-edit-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedId(entry.id); setTimeout(() => { setFormMode('edit'); setEditData(entry.data); setShowForm(true); }, 0); }}
|
||||
title="Edit"
|
||||
type="button"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="nui-mgmt-footer">
|
||||
<div className="nui-mgmt-footer-info">
|
||||
{filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'}
|
||||
{searchTerm && ` (filtered)`}
|
||||
</div>
|
||||
<div className="nui-mgmt-footer-actions">
|
||||
{selectedEntry && (
|
||||
<>
|
||||
<button
|
||||
className="nui-mgmt-delete-btn"
|
||||
onClick={handleDeleteClick}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="nui-mgmt-footer-edit-btn"
|
||||
onClick={handleEditClick}
|
||||
type="button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'select' && (
|
||||
<button
|
||||
className="nui-mgmt-select-btn"
|
||||
onClick={handleSelectClick}
|
||||
disabled={!selectedEntry}
|
||||
type="button"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form overlay (renders on top, z-index 13000) */}
|
||||
<FormOverlay
|
||||
isOpen={showForm}
|
||||
onClose={() => setShowForm(false)}
|
||||
title={formMode === 'edit' ? `Edit ${nomenclature.name} Entry` : `Add New ${nomenclature.name} Entry`}
|
||||
>
|
||||
<NomenclatureEntryForm
|
||||
fields={sortedFields}
|
||||
mode={formMode}
|
||||
initialData={editData}
|
||||
onSave={handleFormSave}
|
||||
onCancel={() => setShowForm(false)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</FormOverlay>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -10,3 +10,146 @@
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Load Report Dialog */
|
||||
.load-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.load-dialog {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.load-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.load-dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.load-dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.load-dialog-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.load-dialog-actions {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.load-dialog-new-btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.load-dialog-new-btn:hover {
|
||||
background: #5a6fd6;
|
||||
}
|
||||
|
||||
.load-dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.load-dialog-loading,
|
||||
.load-dialog-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.load-dialog-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.load-dialog-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.load-dialog-item:hover {
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.load-dialog-item--current {
|
||||
background: #e8f0ff;
|
||||
}
|
||||
|
||||
.load-dialog-item--current:hover {
|
||||
background: #dde8ff;
|
||||
}
|
||||
|
||||
.load-dialog-item-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.load-dialog-item-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.load-dialog-item-date {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.load-dialog-item-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.load-dialog-item-delete:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
+166
@@ -6,6 +6,7 @@ import EditorCanvas from './EditorCanvas';
|
||||
import ConfigPanel from './ConfigPanel';
|
||||
import CharacterPalette from './CharacterPalette';
|
||||
import ObjectInspector from './ObjectInspector';
|
||||
import api from '../../services/api';
|
||||
import './ReportEditor.css';
|
||||
|
||||
function ReportEditorContent() {
|
||||
@@ -25,6 +26,13 @@ function ReportEditorContent() {
|
||||
const [selectedChar, setSelectedChar] = useState('─');
|
||||
const [showCharacterPalette, setShowCharacterPalette] = useState(false);
|
||||
|
||||
// Save/Load state
|
||||
const [reportId, setReportId] = useState(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
const [savedReports, setSavedReports] = useState([]);
|
||||
const [loadingReports, setLoadingReports] = useState(false);
|
||||
|
||||
const handleAddElement = (element) => {
|
||||
setReport(prev => {
|
||||
// Check if the new element is positioned inside a band
|
||||
@@ -178,6 +186,112 @@ function ReportEditorContent() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleReportNameChange = (name) => {
|
||||
setReport(prev => ({ ...prev, name }));
|
||||
};
|
||||
|
||||
// Save report to backend
|
||||
const handleSave = async () => {
|
||||
if (!report.name.trim()) {
|
||||
alert('Please enter a report name');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: report.name,
|
||||
page_width: report.pageWidth,
|
||||
page_height: report.pageHeight,
|
||||
api_endpoint: report.apiEndpoint,
|
||||
elements: report.elements
|
||||
};
|
||||
|
||||
if (reportId) {
|
||||
// Update existing report
|
||||
await api.put(`/api/reports/${reportId}/`, payload);
|
||||
} else {
|
||||
// Create new report
|
||||
const res = await api.post('/api/reports/', payload);
|
||||
setReportId(res.data.id);
|
||||
}
|
||||
alert('Report saved successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to save report:', err);
|
||||
alert('Failed to save report: ' + (err.response?.data?.detail || err.message));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load reports list for dialog
|
||||
const handleLoadClick = async () => {
|
||||
setLoadingReports(true);
|
||||
setShowLoadDialog(true);
|
||||
try {
|
||||
const res = await api.get('/api/reports/');
|
||||
setSavedReports(res.data.results || res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reports list:', err);
|
||||
alert('Failed to load reports list');
|
||||
} finally {
|
||||
setLoadingReports(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load a specific report
|
||||
const handleLoadReport = async (id) => {
|
||||
try {
|
||||
const res = await api.get(`/api/reports/${id}/`);
|
||||
const data = res.data;
|
||||
setReport({
|
||||
name: data.name,
|
||||
pageWidth: data.page_width,
|
||||
pageHeight: data.page_height,
|
||||
apiEndpoint: data.api_endpoint || '',
|
||||
elements: data.elements || []
|
||||
});
|
||||
setReportId(data.id);
|
||||
setSelectedElementIds([]);
|
||||
setShowLoadDialog(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load report:', err);
|
||||
alert('Failed to load report');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a report
|
||||
const handleDeleteReport = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this report?')) return;
|
||||
try {
|
||||
await api.delete(`/api/reports/${id}/`);
|
||||
setSavedReports(prev => prev.filter(r => r.id !== id));
|
||||
if (reportId === id) {
|
||||
setReportId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete report:', err);
|
||||
alert('Failed to delete report');
|
||||
}
|
||||
};
|
||||
|
||||
// New report
|
||||
const handleNewReport = () => {
|
||||
if (report.elements.length > 0) {
|
||||
if (!window.confirm('Discard current report and create a new one?')) return;
|
||||
}
|
||||
setReport({
|
||||
name: 'Untitled Report',
|
||||
pageWidth: 80,
|
||||
pageHeight: 66,
|
||||
apiEndpoint: '',
|
||||
elements: []
|
||||
});
|
||||
setReportId(null);
|
||||
setSelectedElementIds([]);
|
||||
setShowLoadDialog(false);
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
@@ -217,6 +331,11 @@ function ReportEditorContent() {
|
||||
previewMode={previewMode}
|
||||
onTogglePreview={handleTogglePreview}
|
||||
onConfigureAPI={() => setShowConfigPanel(true)}
|
||||
onSave={handleSave}
|
||||
onLoad={handleLoadClick}
|
||||
reportName={report.name}
|
||||
onReportNameChange={handleReportNameChange}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<div className="editor-layout">
|
||||
<EditorCanvas
|
||||
@@ -257,6 +376,53 @@ function ReportEditorContent() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Load Report Dialog */}
|
||||
{showLoadDialog && (
|
||||
<div className="load-dialog-overlay" onClick={() => setShowLoadDialog(false)}>
|
||||
<div className="load-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="load-dialog-header">
|
||||
<h2>Load Report</h2>
|
||||
<button className="load-dialog-close" onClick={() => setShowLoadDialog(false)}>×</button>
|
||||
</div>
|
||||
<div className="load-dialog-actions">
|
||||
<button className="load-dialog-new-btn" onClick={handleNewReport}>
|
||||
+ New Report
|
||||
</button>
|
||||
</div>
|
||||
<div className="load-dialog-content">
|
||||
{loadingReports ? (
|
||||
<div className="load-dialog-loading">Loading...</div>
|
||||
) : savedReports.length === 0 ? (
|
||||
<div className="load-dialog-empty">No saved reports</div>
|
||||
) : (
|
||||
<ul className="load-dialog-list">
|
||||
{savedReports.map(r => (
|
||||
<li
|
||||
key={r.id}
|
||||
className={`load-dialog-item ${reportId === r.id ? 'load-dialog-item--current' : ''}`}
|
||||
>
|
||||
<div className="load-dialog-item-info" onClick={() => handleLoadReport(r.id)}>
|
||||
<span className="load-dialog-item-name">{r.name}</span>
|
||||
<span className="load-dialog-item-date">
|
||||
{new Date(r.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="load-dialog-item-delete"
|
||||
onClick={() => handleDeleteReport(r.id)}
|
||||
title="Delete report"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,3 +50,33 @@
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toolbar-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.toolbar-group--file {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toolbar-report-name {
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.toolbar-report-name::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.toolbar-report-name:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
+32
-1
@@ -8,7 +8,12 @@ function Toolbar({
|
||||
onBorderStyleChange,
|
||||
previewMode = false,
|
||||
onTogglePreview,
|
||||
onConfigureAPI
|
||||
onConfigureAPI,
|
||||
onSave,
|
||||
onLoad,
|
||||
reportName,
|
||||
onReportNameChange,
|
||||
isSaving = false
|
||||
}) {
|
||||
return (
|
||||
<div className="toolbar">
|
||||
@@ -107,6 +112,32 @@ function Toolbar({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="toolbar-separator"></div>
|
||||
<div className="toolbar-group toolbar-group--file">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-report-name"
|
||||
value={reportName || ''}
|
||||
onChange={e => onReportNameChange && onReportNameChange(e.target.value)}
|
||||
placeholder="Report name"
|
||||
title="Report name"
|
||||
/>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
title="Save Report"
|
||||
>
|
||||
{isSaving ? '...' : '💾 Save'}
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={onLoad}
|
||||
title="Load Report"
|
||||
>
|
||||
📂 Load
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +358,18 @@ export const NomenclatureProvider = ({ children }) => {
|
||||
|
||||
const { object, operation, data } = message;
|
||||
|
||||
// Handle nomenclature definition and entry updates - notify all subscribers
|
||||
if (object === 'nomenclature' || object === 'nomenclature_entry') {
|
||||
subscribersRef.current.forEach(callback => {
|
||||
try {
|
||||
callback(message);
|
||||
} catch (error) {
|
||||
console.error('Error in nomenclature entry update subscriber:', error);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle invoice updates - notify all subscribers
|
||||
if (object === 'invoice' || object === 'notice') {
|
||||
subscribersRef.current.forEach(callback => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useNomenclatures } from './NomenclatureContext';
|
||||
|
||||
const NomenclatureDataContext = createContext();
|
||||
|
||||
export const useNomenclatureData = () => {
|
||||
const context = useContext(NomenclatureDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useNomenclatureData must be used within a NomenclatureDataProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const NomenclatureDataProvider = ({ children }) => {
|
||||
const { subscribeToUpdates } = useNomenclatures();
|
||||
|
||||
// definitions: { CODE: { id, code, name, kind, display_field, applies_to, sort_order, fields: [...] } }
|
||||
const [definitions, setDefinitions] = useState({});
|
||||
// entries: { CODE: [ { id, data: {...}, is_active, display_value } ] }
|
||||
const [entries, setEntries] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Ref to track definitions for SSE handler (avoids stale closure)
|
||||
const definitionsRef = useRef({});
|
||||
useEffect(() => { definitionsRef.current = definitions; }, [definitions]);
|
||||
|
||||
// Load all nomenclature definitions and their entries
|
||||
const loadAll = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get('/api/nomenclatures/');
|
||||
const data = res.data.results || res.data;
|
||||
const noms = Array.isArray(data) ? data : [];
|
||||
|
||||
// Build definitions map keyed by code
|
||||
const defsMap = {};
|
||||
for (const nom of noms) {
|
||||
defsMap[nom.code] = nom;
|
||||
}
|
||||
setDefinitions(defsMap);
|
||||
definitionsRef.current = defsMap;
|
||||
|
||||
// Load entries for all lookup nomenclatures in parallel
|
||||
const lookupNoms = noms.filter(n => n.kind === 'lookup');
|
||||
const entryResults = await Promise.allSettled(
|
||||
lookupNoms.map(nom =>
|
||||
api.get(`/api/nomenclatures/${nom.id}/entries/`).then(res => ({
|
||||
code: nom.code,
|
||||
entries: Array.isArray(res.data) ? res.data : [],
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const entriesMap = {};
|
||||
for (const result of entryResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
entriesMap[result.value.code] = result.value.entries;
|
||||
}
|
||||
}
|
||||
setEntries(entriesMap);
|
||||
setIsLoaded(true);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load nomenclature data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, [loadAll]);
|
||||
|
||||
// Subscribe to SSE updates for nomenclature and nomenclature_entry events
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToUpdates((message) => {
|
||||
// Nomenclature definition changed - reload everything
|
||||
if (message.object === 'nomenclature') {
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.object !== 'nomenclature_entry') return;
|
||||
|
||||
const { operation, data } = message;
|
||||
const nomCode = data?.nomenclature_code;
|
||||
if (!nomCode) return;
|
||||
|
||||
setEntries(prev => {
|
||||
const currentEntries = prev[nomCode] || [];
|
||||
|
||||
if (operation === 'insert') {
|
||||
const exists = currentEntries.some(e => e.id === data.id);
|
||||
if (exists) return prev;
|
||||
return { ...prev, [nomCode]: [...currentEntries, data] };
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const updated = currentEntries.map(e => e.id === data.id ? { ...e, ...data } : e);
|
||||
return { ...prev, [nomCode]: updated };
|
||||
}
|
||||
|
||||
if (operation === 'delete') {
|
||||
return { ...prev, [nomCode]: currentEntries.filter(e => e.id !== data.id) };
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [subscribeToUpdates, loadAll]);
|
||||
|
||||
// Helper: get active entries for a nomenclature code
|
||||
const getActiveEntries = useCallback((code) => {
|
||||
return (entries[code] || []).filter(e => e.is_active);
|
||||
}, [entries]);
|
||||
|
||||
// Helper: get display value for a specific entry
|
||||
const getDisplayValue = useCallback((code, entryId) => {
|
||||
const entry = (entries[code] || []).find(e => e.id === entryId);
|
||||
return entry?.display_value || '';
|
||||
}, [entries]);
|
||||
|
||||
// CRUD: create entry
|
||||
const createEntry = useCallback(async (nomenclatureId, data) => {
|
||||
const res = await api.post(`/api/nomenclatures/${nomenclatureId}/entries/`, { data });
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
// CRUD: update entry
|
||||
const updateEntry = useCallback(async (entryId, payload) => {
|
||||
const res = await api.patch(`/api/nomenclature-entries/${entryId}/`, payload);
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
// CRUD: delete entry
|
||||
const deleteEntry = useCallback(async (entryId) => {
|
||||
await api.delete(`/api/nomenclature-entries/${entryId}/`);
|
||||
}, []);
|
||||
|
||||
// CRUD: toggle is_active
|
||||
const toggleEntryActive = useCallback(async (entryId, currentState) => {
|
||||
const res = await api.patch(`/api/nomenclature-entries/${entryId}/`, {
|
||||
is_active: !currentState,
|
||||
});
|
||||
return res.data;
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
definitions,
|
||||
entries,
|
||||
isLoading,
|
||||
isLoaded,
|
||||
error,
|
||||
getActiveEntries,
|
||||
getDisplayValue,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
toggleEntryActive,
|
||||
refreshAll: loadAll,
|
||||
};
|
||||
|
||||
return (
|
||||
<NomenclatureDataContext.Provider value={value}>
|
||||
{children}
|
||||
</NomenclatureDataContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
python .\serial_bridge\app.py
|
||||
@@ -0,0 +1 @@
|
||||
python .\test_comport_writer\test_writer.py
|
||||
Reference in New Issue
Block a user