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(python manage.py migrate:*)",
"Bash(tree:*)", "Bash(tree:*)",
"Bash(dir /b \"c:\\\\dev_projects\\\\ScalesApp\\\\frontend\\\\src\\\\contexts\")", "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"],
},
),
]

@ -36,3 +36,24 @@ class ComPortReading(models.Model):
def __str__(self): def __str__(self):
return f"{self.port} - {self.timestamp}" 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 rest_framework import serializers
from django.contrib.auth.password_validation import validate_password 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 vehicles.models import Vehicle, VehicleExtra
from nomenclatures.models import Nomenclature, NomenclatureField from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@ -80,14 +80,58 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
fields = ['id', 'data'] fields = ['id', 'data']
read_only_fields = ['id'] 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): class VehicleSerializer(serializers.ModelSerializer):
extra = VehicleExtraSerializer(required=False, allow_null=True) 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: class Meta:
model = Vehicle model = Vehicle
fields = ['id', 'vehicle_number', 'extra'] fields = ['id', 'vehicle_number', 'tare', 'tare_date', 'tare_user', 'tare_user_name',
read_only_fields = ['id'] '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): def create(self, validated_data):
extra_data = validated_data.pop('extra', None) extra_data = validated_data.pop('extra', None)
@ -101,19 +145,17 @@ class VehicleSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
extra_data = validated_data.pop('extra', None) extra_data = validated_data.pop('extra', None)
# Update Vehicle fields for attr, value in validated_data.items():
instance.vehicle_number = validated_data.get('vehicle_number', instance.vehicle_number) setattr(instance, attr, value)
instance.save() instance.save()
# Handle VehicleExtra update/creation # Handle VehicleExtra update/creation
if extra_data is not None: if extra_data is not None:
if hasattr(instance, 'extra'): if hasattr(instance, 'extra'):
# Update existing VehicleExtra
for attr, value in extra_data.items(): for attr, value in extra_data.items():
setattr(instance.extra, attr, value) setattr(instance.extra, attr, value)
instance.extra.save() instance.extra.save()
else: else:
# Create new VehicleExtra
VehicleExtra.objects.create(vehicle=instance, **extra_data) VehicleExtra.objects.create(vehicle=instance, **extra_data)
return instance return instance
@ -127,7 +169,7 @@ class VehicleSerializer(serializers.ModelSerializer):
class NomenclatureFieldSerializer(serializers.ModelSerializer): class NomenclatureFieldSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = NomenclatureField model = NomenclatureField
fields = ['id', 'key', 'field_type'] fields = ['id', 'key', 'label', 'field_type', 'required', 'choices', 'sort_order']
read_only_fields = ['id'] read_only_fields = ['id']
def validate_key(self, value): def validate_key(self, value):
@ -143,17 +185,80 @@ class NomenclatureFieldSerializer(serializers.ModelSerializer):
) )
return value 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): 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: class Meta:
model = Nomenclature 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'] read_only_fields = ['id']
def create(self, validated_data): def create(self, validated_data):
fields_data = validated_data.pop('nomenclaturefield_set', []) fields_data = validated_data.pop('fields', [])
nomenclature = Nomenclature.objects.create(**validated_data) nomenclature = Nomenclature.objects.create(**validated_data)
for field_data in fields_data: for field_data in fields_data:
@ -162,19 +267,14 @@ class NomenclatureSerializer(serializers.ModelSerializer):
return nomenclature return nomenclature
def update(self, instance, validated_data): def update(self, instance, validated_data):
fields_data = validated_data.pop('nomenclaturefield_set', None) fields_data = validated_data.pop('fields', None)
# Update Nomenclature fields for attr, value in validated_data.items():
instance.code = validated_data.get('code', instance.code) setattr(instance, attr, value)
instance.name = validated_data.get('name', instance.name)
instance.applies_to = validated_data.get('applies_to', instance.applies_to)
instance.save() instance.save()
# Handle fields update - replace all fields
if fields_data is not None: if fields_data is not None:
# Delete existing fields instance.fields.all().delete()
instance.nomenclaturefield_set.all().delete()
# Create new fields
for field_data in fields_data: for field_data in fields_data:
NomenclatureField.objects.create(nomenclature=instance, **field_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)}" f"Invalid applies_to value. Must be one of: {', '.join(valid_choices)}"
) )
return value 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'readings', views.ComPortReadingViewSet, basename='reading')
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle') router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature') 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 = [ urlpatterns = [
# JWT token endpoints # JWT token endpoints

@ -2,10 +2,16 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from .models import ComPortReading, User from .models import ComPortReading, User, Report
from .serializers import ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer from .serializers import (
ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
NomenclatureEntrySerializer, ReportSerializer,
)
from django.utils import timezone
from vehicles.models import Vehicle 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): class UserViewSet(viewsets.ModelViewSet):
@ -116,14 +122,31 @@ class VehicleViewSet(viewsets.ModelViewSet):
partial_update: Partial update of vehicle partial_update: Partial update of vehicle
destroy: Delete a vehicle (cascades to VehicleExtra) destroy: Delete a vehicle (cascades to VehicleExtra)
by_number: Get vehicle by vehicle_number 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 serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
filterset_fields = ['vehicle_number'] filterset_fields = ['vehicle_number']
search_fields = ['vehicle_number'] search_fields = ['vehicle_number']
ordering = ['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') @action(detail=False, methods=['get'], url_path='by-number')
def by_number(self, request): def by_number(self, request):
"""Get vehicle by vehicle_number query parameter""" """Get vehicle by vehicle_number query parameter"""
@ -144,6 +167,48 @@ class VehicleViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND 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): class NomenclatureViewSet(viewsets.ModelViewSet):
""" """
@ -154,16 +219,30 @@ class NomenclatureViewSet(viewsets.ModelViewSet):
retrieve: Get a specific nomenclature by ID retrieve: Get a specific nomenclature by ID
update: Full update of nomenclature and fields update: Full update of nomenclature and fields
partial_update: Partial update of nomenclature 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_code: Get nomenclature by code
by_applies_to: Filter nomenclatures by applies_to type 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 serializer_class = NomenclatureSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
filterset_fields = ['applies_to', 'code'] filterset_fields = ['applies_to', 'code', 'kind']
search_fields = ['code', 'name'] 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') @action(detail=False, methods=['get'], url_path='by-code')
def by_code(self, request): def by_code(self, request):
@ -209,3 +288,81 @@ class NomenclatureViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(nomenclatures, many=True) serializer = self.get_serializer(nomenclatures, many=True)
return Response(serializer.data) 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 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 from django.db import models
# Create your models here.
class Nomenclature(models.Model): 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) code = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=255) 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, 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): class NomenclatureField(models.Model):
TEXT = "text" TEXT = "text"
@ -23,7 +49,42 @@ class NomenclatureField(models.Model):
] ]
nomenclature = models.ForeignKey( nomenclature = models.ForeignKey(
Nomenclature, on_delete=models.CASCADE Nomenclature,
on_delete=models.CASCADE,
related_name="fields",
) )
key = models.CharField(max_length=50) key = models.CharField(max_length=50)
label = models.CharField(max_length=100, blank=True)
field_type = models.CharField(max_length=20, choices=FIELD_TYPES) 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 django.db import models
from api.models import User
# Create your models here. # Create your models here.
class Vehicle(models.Model): class Vehicle(models.Model):
vehicle_number = models.CharField(max_length=15, unique=True) 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): def __str__(self):
return f"{self.vehicle_number}" return f"{self.vehicle_number}"

@ -7,6 +7,7 @@ import Main from './components/Main';
import ReportEditor from './components/ReportEditor/ReportEditor'; import ReportEditor from './components/ReportEditor/ReportEditor';
import './App.css'; import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext'; import { NomenclatureProvider } from './contexts/NomenclatureContext';
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
// function MainApp() { // function MainApp() {
// const [selectedPort, setSelectedPort] = useState(null); // const [selectedPort, setSelectedPort] = useState(null);
@ -55,10 +56,12 @@ function AppContent() {
return ( return (
<NomenclatureProvider> <NomenclatureProvider>
<Routes> <NomenclatureDataProvider>
<Route path="/" element={<Main />} /> <Routes>
<Route path="/report-editor" element={<ReportEditor />} /> <Route path="/" element={<Main />} />
</Routes> <Route path="/report-editor" element={<ReportEditor />} />
</Routes>
</NomenclatureDataProvider>
</NomenclatureProvider> </NomenclatureProvider>
); );
} }

@ -2,12 +2,14 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay'; import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './Header.css'; import './Header.css';
function Header() { function Header() {
const { currentUser, logout } = useAuth(); const { currentUser, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false); const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const getInitials = (user) => { const getInitials = (user) => {
if (user.first_name && user.last_name) { if (user.first_name && user.last_name) {
@ -32,6 +34,13 @@ function Header() {
> >
📝 📝
</button> </button>
<button
className="nav-button"
onClick={() => setShowNomenclatureManager(true)}
title="Nomenclatures"
>
📋
</button>
<h1>ScalesApp - Real-time Data Monitor</h1> <h1>ScalesApp - Real-time Data Monitor</h1>
</div> </div>
<div className="header-right"> <div className="header-right">
@ -54,6 +63,10 @@ function Header() {
{showPasswordOverlay && ( {showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} /> <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; flex: 1;
overflow: hidden; 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 ConfigPanel from './ConfigPanel';
import CharacterPalette from './CharacterPalette'; import CharacterPalette from './CharacterPalette';
import ObjectInspector from './ObjectInspector'; import ObjectInspector from './ObjectInspector';
import api from '../../services/api';
import './ReportEditor.css'; import './ReportEditor.css';
function ReportEditorContent() { function ReportEditorContent() {
@ -25,6 +26,13 @@ function ReportEditorContent() {
const [selectedChar, setSelectedChar] = useState('─'); const [selectedChar, setSelectedChar] = useState('─');
const [showCharacterPalette, setShowCharacterPalette] = useState(false); 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) => { const handleAddElement = (element) => {
setReport(prev => { setReport(prev => {
// Check if the new element is positioned inside a band // 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 // Handle keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@ -217,6 +331,11 @@ function ReportEditorContent() {
previewMode={previewMode} previewMode={previewMode}
onTogglePreview={handleTogglePreview} onTogglePreview={handleTogglePreview}
onConfigureAPI={() => setShowConfigPanel(true)} onConfigureAPI={() => setShowConfigPanel(true)}
onSave={handleSave}
onLoad={handleLoadClick}
reportName={report.name}
onReportNameChange={handleReportNameChange}
isSaving={isSaving}
/> />
<div className="editor-layout"> <div className="editor-layout">
<EditorCanvas <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> </div>
); );
} }

@ -50,3 +50,33 @@
border-color: rgba(255, 255, 255, 0.5); border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 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, onBorderStyleChange,
previewMode = false, previewMode = false,
onTogglePreview, onTogglePreview,
onConfigureAPI onConfigureAPI,
onSave,
onLoad,
reportName,
onReportNameChange,
isSaving = false
}) { }) {
return ( return (
<div className="toolbar"> <div className="toolbar">
@ -107,6 +112,32 @@ function Toolbar({
</button> </button>
)} )}
</div> </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> </div>
); );
} }

@ -358,6 +358,18 @@ export const NomenclatureProvider = ({ children }) => {
const { object, operation, data } = message; 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 // Handle invoice updates - notify all subscribers
if (object === 'invoice' || object === 'notice') { if (object === 'invoice' || object === 'notice') {
subscribersRef.current.forEach(callback => { 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