added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data

master
kikimor 3 weeks ago
parent ed35a90cc0
commit 6a42099169

@ -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"],
},
),
]

@ -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

@ -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

@ -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}"))

@ -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,
),
),
]

@ -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}>&times;</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"
>
&#9998;
</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"
>
&times;
</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"
>
&times;
</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}`}
>
&#8230;
</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">&times;</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"
>
&#9998;
</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;
}

@ -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);
}

@ -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
Loading…
Cancel
Save