From 6a42099169c11b836d9c28b9f98de6a0152f2f2c Mon Sep 17 00:00:00 2001 From: kikimor Date: Tue, 10 Feb 2026 17:32:33 +0200 Subject: [PATCH] added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data --- .claude/settings.local.json | 10 +- CLAUDE.md | 360 ++++++++++ .../api/migrations/0002_add_report_model.py | 52 ++ backend/api/models.py | 25 +- backend/api/serializers.py | 181 ++++- backend/api/urls.py | 2 + backend/api/views.py | 179 ++++- backend/nomenclatures/admin.py | 20 +- ...002_alter_nomenclature_options_and_more.py | 111 ++++ backend/nomenclatures/models.py | 71 +- ..._gross_date_vehicle_gross_user_and_more.py | 58 ++ ...er_pid_vehicle_trailer1_number_and_more.py | 31 + backend/vehicles/models.py | 15 + frontend/src/{App.js => App.jsx} | 11 +- .../{DataDisplay.js => DataDisplay.jsx} | 0 .../src/components/{Header.js => Header.jsx} | 13 + frontend/src/components/Main.css | 391 +++++++++++ frontend/src/components/Main.js | 35 - frontend/src/components/Main.jsx | 308 +++++++++ .../NomenclatureManager.css | 616 ++++++++++++++++++ .../NomenclatureManager.jsx | 392 +++++++++++ .../components/NomenclatureUI/FormOverlay.css | 80 +++ .../components/NomenclatureUI/FormOverlay.jsx | 67 ++ .../NomenclatureUI/NomenclatureDropdown.css | 118 ++++ .../NomenclatureUI/NomenclatureDropdown.jsx | 158 +++++ .../NomenclatureUI/NomenclatureEntryForm.css | 139 ++++ .../NomenclatureUI/NomenclatureEntryForm.jsx | 171 +++++ .../NomenclatureManagementOverlay.css | 383 +++++++++++ .../NomenclatureManagementOverlay.jsx | 369 +++++++++++ .../{ProtectedRoute.js => ProtectedRoute.jsx} | 0 .../{BandContext.js => BandContext.jsx} | 0 .../{BandElement.js => BandElement.jsx} | 0 ...aracterPalette.js => CharacterPalette.jsx} | 0 .../{ConfigPanel.js => ConfigPanel.jsx} | 0 .../{DBTextField.js => DBTextField.jsx} | 0 .../{DataContext.js => DataContext.jsx} | 0 .../{EditorCanvas.js => EditorCanvas.jsx} | 0 .../{FrameElement.js => FrameElement.jsx} | 0 .../{HorizontalLine.js => HorizontalLine.jsx} | 0 ...ObjectInspector.js => ObjectInspector.jsx} | 0 .../components/ReportEditor/ReportEditor.css | 143 ++++ .../{ReportEditor.js => ReportEditor.jsx} | 166 +++++ .../{ResizeHandles.js => ResizeHandles.jsx} | 0 .../{SymbolElement.js => SymbolElement.jsx} | 0 .../{TextField.js => TextField.jsx} | 0 .../src/components/ReportEditor/Toolbar.css | 30 + .../ReportEditor/{Toolbar.js => Toolbar.jsx} | 33 +- .../{VerticalLine.js => VerticalLine.jsx} | 0 .../{ElementWrapper.js => ElementWrapper.jsx} | 0 .../{useElementDrag.js => useElementDrag.jsx} | 0 ...ntSelection.js => useElementSelection.jsx} | 0 .../models/{Element.js => Element.jsx} | 0 ...rdOverlay.js => ChangePasswordOverlay.jsx} | 0 .../components/Users/{Login.js => Login.jsx} | 0 .../{AuthContext.js => AuthContext.jsx} | 0 frontend/src/contexts/NomenclatureContext.jsx | 12 + .../src/contexts/NomenclatureDataContext.jsx | 174 +++++ frontend/src/{index.js => index.jsx} | 0 run_comport_reader.bat | 1 + run_test.bat | 1 + 60 files changed, 4845 insertions(+), 81 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/api/migrations/0002_add_report_model.py create mode 100644 backend/nomenclatures/migrations/0002_alter_nomenclature_options_and_more.py create mode 100644 backend/vehicles/migrations/0002_vehicle_gross_vehicle_gross_date_vehicle_gross_user_and_more.py create mode 100644 backend/vehicles/migrations/0003_vehicle_driver_pid_vehicle_trailer1_number_and_more.py rename frontend/src/{App.js => App.jsx} (86%) rename frontend/src/components/{DataDisplay.js => DataDisplay.jsx} (100%) rename frontend/src/components/{Header.js => Header.jsx} (79%) create mode 100644 frontend/src/components/Main.css delete mode 100644 frontend/src/components/Main.js create mode 100644 frontend/src/components/Main.jsx create mode 100644 frontend/src/components/NomenclatureManager/NomenclatureManager.css create mode 100644 frontend/src/components/NomenclatureManager/NomenclatureManager.jsx create mode 100644 frontend/src/components/NomenclatureUI/FormOverlay.css create mode 100644 frontend/src/components/NomenclatureUI/FormOverlay.jsx create mode 100644 frontend/src/components/NomenclatureUI/NomenclatureDropdown.css create mode 100644 frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx create mode 100644 frontend/src/components/NomenclatureUI/NomenclatureEntryForm.css create mode 100644 frontend/src/components/NomenclatureUI/NomenclatureEntryForm.jsx create mode 100644 frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css create mode 100644 frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.jsx rename frontend/src/components/{ProtectedRoute.js => ProtectedRoute.jsx} (100%) rename frontend/src/components/ReportEditor/{BandContext.js => BandContext.jsx} (100%) rename frontend/src/components/ReportEditor/{BandElement.js => BandElement.jsx} (100%) rename frontend/src/components/ReportEditor/{CharacterPalette.js => CharacterPalette.jsx} (100%) rename frontend/src/components/ReportEditor/{ConfigPanel.js => ConfigPanel.jsx} (100%) rename frontend/src/components/ReportEditor/{DBTextField.js => DBTextField.jsx} (100%) rename frontend/src/components/ReportEditor/{DataContext.js => DataContext.jsx} (100%) rename frontend/src/components/ReportEditor/{EditorCanvas.js => EditorCanvas.jsx} (100%) rename frontend/src/components/ReportEditor/{FrameElement.js => FrameElement.jsx} (100%) rename frontend/src/components/ReportEditor/{HorizontalLine.js => HorizontalLine.jsx} (100%) rename frontend/src/components/ReportEditor/{ObjectInspector.js => ObjectInspector.jsx} (100%) rename frontend/src/components/ReportEditor/{ReportEditor.js => ReportEditor.jsx} (61%) rename frontend/src/components/ReportEditor/{ResizeHandles.js => ResizeHandles.jsx} (100%) rename frontend/src/components/ReportEditor/{SymbolElement.js => SymbolElement.jsx} (100%) rename frontend/src/components/ReportEditor/{TextField.js => TextField.jsx} (100%) rename frontend/src/components/ReportEditor/{Toolbar.js => Toolbar.jsx} (79%) rename frontend/src/components/ReportEditor/{VerticalLine.js => VerticalLine.jsx} (100%) rename frontend/src/components/ReportEditor/components/{ElementWrapper.js => ElementWrapper.jsx} (100%) rename frontend/src/components/ReportEditor/hooks/{useElementDrag.js => useElementDrag.jsx} (100%) rename frontend/src/components/ReportEditor/hooks/{useElementSelection.js => useElementSelection.jsx} (100%) rename frontend/src/components/ReportEditor/models/{Element.js => Element.jsx} (100%) rename frontend/src/components/Users/{ChangePasswordOverlay.js => ChangePasswordOverlay.jsx} (100%) rename frontend/src/components/Users/{Login.js => Login.jsx} (100%) rename frontend/src/contexts/{AuthContext.js => AuthContext.jsx} (100%) create mode 100644 frontend/src/contexts/NomenclatureDataContext.jsx rename frontend/src/{index.js => index.jsx} (100%) create mode 100644 run_comport_reader.bat create mode 100644 run_test.bat diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a3bbff7..82673f9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,15 @@ "Bash(python manage.py migrate:*)", "Bash(tree:*)", "Bash(dir /b \"c:\\\\dev_projects\\\\ScalesApp\\\\frontend\\\\src\\\\contexts\")", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(python manage.py makemigrations:*)", + "Bash(tasklist:*)", + "Bash(findstr:*)", + "Bash(python manage.py:*)", + "Bash(cat:*)", + "Bash(netstat:*)", + "Bash(taskkill:*)", + "Bash(DJANGO_SETTINGS_MODULE=scalesapp.settings python:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8ff953e --- /dev/null +++ b/CLAUDE.md @@ -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=` 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` diff --git a/backend/api/migrations/0002_add_report_model.py b/backend/api/migrations/0002_add_report_model.py new file mode 100644 index 0000000..de7a913 --- /dev/null +++ b/backend/api/migrations/0002_add_report_model.py @@ -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"], + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 5601014..4e1e57f 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -26,13 +26,34 @@ class ComPortReading(models.Model): data = models.TextField() timestamp = models.DateTimeField(auto_now_add=True) source_ip = models.GenericIPAddressField(null=True, blank=True) - + class Meta: ordering = ['-timestamp'] indexes = [ models.Index(fields=['-timestamp']), models.Index(fields=['port', '-timestamp']), ] - + def __str__(self): return f"{self.port} - {self.timestamp}" + + +class Report(models.Model): + """Model to store report templates for the report editor""" + name = models.CharField(max_length=255) + page_width = models.IntegerField(default=80) + page_height = models.IntegerField(default=66) + api_endpoint = models.CharField(max_length=500, blank=True, default='') + elements = models.JSONField(default=list) + created_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='reports' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-updated_at'] + + def __str__(self): + return self.name diff --git a/backend/api/serializers.py b/backend/api/serializers.py index b1a0958..9810f9b 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from django.contrib.auth.password_validation import validate_password -from .models import ComPortReading, User +from .models import ComPortReading, User, Report from vehicles.models import Vehicle, VehicleExtra -from nomenclatures.models import Nomenclature, NomenclatureField +from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry class UserSerializer(serializers.ModelSerializer): @@ -80,14 +80,58 @@ class VehicleExtraSerializer(serializers.ModelSerializer): fields = ['id', 'data'] read_only_fields = ['id'] + def validate_data(self, value): + if not value: + return value + + nomenclatures = { + n.code: n for n in + Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries') + } + + errors = {} + for key, val in value.items(): + if key not in nomenclatures: + errors[key] = f"Unknown nomenclature code '{key}'." + continue + + nom = nomenclatures[key] + + if nom.kind == Nomenclature.LOOKUP: + if not isinstance(val, int): + errors[key] = "Must be a nomenclature entry ID (integer)." + elif not nom.entries.filter(id=val, is_active=True).exists(): + errors[key] = f"Entry ID {val} not found in '{nom.code}'." + + elif nom.kind == Nomenclature.FIELD: + field_def = nom.fields.first() + if field_def: + if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str): + errors[key] = "Must be a string." + elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)): + errors[key] = "Must be a number." + elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool): + errors[key] = "Must be a boolean." + elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []): + errors[key] = f"Must be one of {field_def.choices}." + + if errors: + raise serializers.ValidationError(errors) + + return value + class VehicleSerializer(serializers.ModelSerializer): extra = VehicleExtraSerializer(required=False, allow_null=True) + tare_user_name = serializers.CharField(source='tare_user.username', read_only=True, default=None) + gross_user_name = serializers.CharField(source='gross_user.username', read_only=True, default=None) class Meta: model = Vehicle - fields = ['id', 'vehicle_number', 'extra'] - read_only_fields = ['id'] + fields = ['id', 'vehicle_number', 'tare', 'tare_date', 'tare_user', 'tare_user_name', + 'gross', 'gross_date', 'gross_user', 'gross_user_name', 'extra'] + read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name', + 'gross_date', 'gross_user', 'gross_user_name'] def create(self, validated_data): extra_data = validated_data.pop('extra', None) @@ -101,19 +145,17 @@ class VehicleSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): extra_data = validated_data.pop('extra', None) - # Update Vehicle fields - instance.vehicle_number = validated_data.get('vehicle_number', instance.vehicle_number) + for attr, value in validated_data.items(): + setattr(instance, attr, value) instance.save() # Handle VehicleExtra update/creation if extra_data is not None: if hasattr(instance, 'extra'): - # Update existing VehicleExtra for attr, value in extra_data.items(): setattr(instance.extra, attr, value) instance.extra.save() else: - # Create new VehicleExtra VehicleExtra.objects.create(vehicle=instance, **extra_data) return instance @@ -127,7 +169,7 @@ class VehicleSerializer(serializers.ModelSerializer): class NomenclatureFieldSerializer(serializers.ModelSerializer): class Meta: model = NomenclatureField - fields = ['id', 'key', 'field_type'] + fields = ['id', 'key', 'label', 'field_type', 'required', 'choices', 'sort_order'] read_only_fields = ['id'] def validate_key(self, value): @@ -143,17 +185,80 @@ class NomenclatureFieldSerializer(serializers.ModelSerializer): ) return value + def validate(self, data): + if data.get('field_type') == NomenclatureField.CHOICE: + choices = data.get('choices') + if not choices or not isinstance(choices, list) or len(choices) == 0: + raise serializers.ValidationError({ + 'choices': 'Choices must be a non-empty list for choice fields.' + }) + elif data.get('choices'): + raise serializers.ValidationError({ + 'choices': 'Only choice-type fields can have choices.' + }) + return data + + +class NomenclatureEntrySerializer(serializers.ModelSerializer): + display_value = serializers.SerializerMethodField() + + class Meta: + model = NomenclatureEntry + fields = ['id', 'data', 'is_active', 'display_value'] + read_only_fields = ['id'] + + def get_display_value(self, obj): + display_key = obj.nomenclature.display_field or "name" + return obj.data.get(display_key, f"Entry #{obj.pk}") + + def validate_data(self, value): + nomenclature = self.context.get('nomenclature') + if not nomenclature: + return value + + field_defs = {f.key: f for f in nomenclature.fields.all()} + + for key in value.keys(): + if key not in field_defs: + raise serializers.ValidationError( + f"Unknown field '{key}'. Valid fields: {list(field_defs.keys())}" + ) + + for key, field_def in field_defs.items(): + if field_def.required and key not in value: + raise serializers.ValidationError( + f"Required field '{key}' is missing." + ) + + for key, val in value.items(): + if key not in field_defs: + continue + field_def = field_defs[key] + + if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str): + raise serializers.ValidationError(f"Field '{key}' must be a string.") + elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)): + raise serializers.ValidationError(f"Field '{key}' must be a number.") + elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool): + raise serializers.ValidationError(f"Field '{key}' must be a boolean.") + elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []): + raise serializers.ValidationError(f"Field '{key}' must be one of {field_def.choices}.") + + return value + class NomenclatureSerializer(serializers.ModelSerializer): - fields = NomenclatureFieldSerializer(many=True, required=False, source='nomenclaturefield_set') + fields = NomenclatureFieldSerializer(many=True, required=False) + entry_count = serializers.IntegerField(source='entries.count', read_only=True) class Meta: model = Nomenclature - fields = ['id', 'code', 'name', 'applies_to', 'fields'] + fields = ['id', 'code', 'name', 'applies_to', 'kind', 'display_field', + 'sort_order', 'fields', 'entry_count'] read_only_fields = ['id'] def create(self, validated_data): - fields_data = validated_data.pop('nomenclaturefield_set', []) + fields_data = validated_data.pop('fields', []) nomenclature = Nomenclature.objects.create(**validated_data) for field_data in fields_data: @@ -162,19 +267,14 @@ class NomenclatureSerializer(serializers.ModelSerializer): return nomenclature def update(self, instance, validated_data): - fields_data = validated_data.pop('nomenclaturefield_set', None) + fields_data = validated_data.pop('fields', None) - # Update Nomenclature fields - instance.code = validated_data.get('code', instance.code) - instance.name = validated_data.get('name', instance.name) - instance.applies_to = validated_data.get('applies_to', instance.applies_to) + for attr, value in validated_data.items(): + setattr(instance, attr, value) instance.save() - # Handle fields update - replace all fields if fields_data is not None: - # Delete existing fields - instance.nomenclaturefield_set.all().delete() - # Create new fields + instance.fields.all().delete() for field_data in fields_data: NomenclatureField.objects.create(nomenclature=instance, **field_data) @@ -192,3 +292,42 @@ class NomenclatureSerializer(serializers.ModelSerializer): f"Invalid applies_to value. Must be one of: {', '.join(valid_choices)}" ) return value + + def validate(self, data): + kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP)) + fields_data = data.get('fields', []) + + if kind == Nomenclature.FIELD and fields_data and len(fields_data) != 1: + raise serializers.ValidationError( + "A 'field' nomenclature must have exactly one field definition." + ) + + if kind == Nomenclature.LOOKUP: + display_field = data.get('display_field', getattr(self.instance, 'display_field', 'name')) + field_keys = [f['key'] for f in fields_data] if fields_data else [] + if field_keys and display_field not in field_keys: + raise serializers.ValidationError( + f"display_field '{display_field}' must match one of the defined field keys." + ) + + return data + + +class ReportSerializer(serializers.ModelSerializer): + created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None) + + class Meta: + model = Report + fields = ['id', 'name', 'page_width', 'page_height', 'api_endpoint', + 'elements', 'created_by', 'created_by_name', 'created_at', 'updated_at'] + read_only_fields = ['id', 'created_by', 'created_by_name', 'created_at', 'updated_at'] + + def validate_name(self, value): + if not value or not value.strip(): + raise serializers.ValidationError("Report name cannot be empty") + return value.strip() + + def validate_elements(self, value): + if not isinstance(value, list): + raise serializers.ValidationError("Elements must be a list") + return value diff --git a/backend/api/urls.py b/backend/api/urls.py index ee2cbff..01822f3 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -11,6 +11,8 @@ router.register(r'users', views.UserViewSet, basename='user') router.register(r'readings', views.ComPortReadingViewSet, basename='reading') router.register(r'vehicles', views.VehicleViewSet, basename='vehicle') router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature') +router.register(r'nomenclature-entries', views.NomenclatureEntryViewSet, basename='nomenclature-entry') +router.register(r'reports', views.ReportViewSet, basename='report') urlpatterns = [ # JWT token endpoints diff --git a/backend/api/views.py b/backend/api/views.py index 5e528c5..9f32db7 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -2,10 +2,16 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny -from .models import ComPortReading, User -from .serializers import ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer +from .models import ComPortReading, User, Report +from .serializers import ( + ComPortReadingSerializer, UserSerializer, UserDetailSerializer, + ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer, + NomenclatureEntrySerializer, ReportSerializer, +) +from django.utils import timezone from vehicles.models import Vehicle -from nomenclatures.models import Nomenclature +from nomenclatures.models import Nomenclature, NomenclatureEntry +from scalesapp.sse import sse_broadcast_update class UserViewSet(viewsets.ModelViewSet): @@ -77,7 +83,7 @@ class ComPortReadingViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(reading) return Response(serializer.data) return Response({'detail': 'No readings yet'}, status=status.HTTP_404_NOT_FOUND) - + @action(detail=False, methods=['get']) def by_port(self, request): """Get readings for a specific port""" @@ -90,7 +96,7 @@ class ComPortReadingViewSet(viewsets.ModelViewSet): readings = ComPortReading.objects.filter(port=port) serializer = self.get_serializer(readings, many=True) return Response(serializer.data) - + def get_client_ip(self, request): """Get client IP address""" x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') @@ -99,7 +105,7 @@ class ComPortReadingViewSet(viewsets.ModelViewSet): else: ip = request.META.get('REMOTE_ADDR') return ip - + def perform_create(self, serializer): """Save the reading with client IP""" serializer.save(source_ip=self.get_client_ip(self.request)) @@ -116,14 +122,31 @@ class VehicleViewSet(viewsets.ModelViewSet): partial_update: Partial update of vehicle destroy: Delete a vehicle (cascades to VehicleExtra) by_number: Get vehicle by vehicle_number + set_tare: Set tare weight from scale reading + set_gross: Set gross weight from scale reading """ - queryset = Vehicle.objects.select_related('extra').all() + queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all() serializer_class = VehicleSerializer permission_classes = [IsAuthenticated] filterset_fields = ['vehicle_number'] search_fields = ['vehicle_number'] ordering = ['vehicle_number'] + def perform_create(self, serializer): + instance = serializer.save() + data = VehicleSerializer(instance).data + sse_broadcast_update('vehicle', 'created', data) + + def perform_update(self, serializer): + instance = serializer.save() + data = VehicleSerializer(instance).data + sse_broadcast_update('vehicle', 'updated', data) + + def perform_destroy(self, instance): + data = VehicleSerializer(instance).data + instance.delete() + sse_broadcast_update('vehicle', 'deleted', data) + @action(detail=False, methods=['get'], url_path='by-number') def by_number(self, request): """Get vehicle by vehicle_number query parameter""" @@ -144,6 +167,48 @@ class VehicleViewSet(viewsets.ModelViewSet): status=status.HTTP_404_NOT_FOUND ) + @action(detail=True, methods=['post'], url_path='set-tare') + def set_tare(self, request, pk=None): + """Set tare weight from scale reading""" + vehicle = self.get_object() + value = request.data.get('value') + if value is None: + return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST) + try: + value = int(value) + except (ValueError, TypeError): + return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST) + + vehicle.tare = value + vehicle.tare_date = timezone.now() + vehicle.tare_user = request.user + vehicle.save() + + serializer = self.get_serializer(vehicle) + sse_broadcast_update('vehicle', 'updated', serializer.data) + return Response(serializer.data) + + @action(detail=True, methods=['post'], url_path='set-gross') + def set_gross(self, request, pk=None): + """Set gross weight from scale reading""" + vehicle = self.get_object() + value = request.data.get('value') + if value is None: + return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST) + try: + value = int(value) + except (ValueError, TypeError): + return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST) + + vehicle.gross = value + vehicle.gross_date = timezone.now() + vehicle.gross_user = request.user + vehicle.save() + + serializer = self.get_serializer(vehicle) + sse_broadcast_update('vehicle', 'updated', serializer.data) + return Response(serializer.data) + class NomenclatureViewSet(viewsets.ModelViewSet): """ @@ -154,16 +219,30 @@ class NomenclatureViewSet(viewsets.ModelViewSet): retrieve: Get a specific nomenclature by ID update: Full update of nomenclature and fields partial_update: Partial update of nomenclature - destroy: Delete a nomenclature (cascades to fields) + destroy: Delete a nomenclature (cascades to fields and entries) by_code: Get nomenclature by code by_applies_to: Filter nomenclatures by applies_to type + entries: List or create entries for a specific nomenclature """ - queryset = Nomenclature.objects.prefetch_related('nomenclaturefield_set').all() + queryset = Nomenclature.objects.prefetch_related('fields').all() serializer_class = NomenclatureSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['applies_to', 'code'] + filterset_fields = ['applies_to', 'code', 'kind'] search_fields = ['code', 'name'] - ordering = ['code'] + ordering = ['sort_order', 'code'] + + def perform_create(self, serializer): + instance = serializer.save() + sse_broadcast_update('nomenclature', 'insert', NomenclatureSerializer(instance).data) + + def perform_update(self, serializer): + instance = serializer.save() + sse_broadcast_update('nomenclature', 'update', NomenclatureSerializer(instance).data) + + def perform_destroy(self, instance): + data = NomenclatureSerializer(instance).data + instance.delete() + sse_broadcast_update('nomenclature', 'delete', data) @action(detail=False, methods=['get'], url_path='by-code') def by_code(self, request): @@ -209,3 +288,81 @@ class NomenclatureViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(nomenclatures, many=True) return Response(serializer.data) + + @action(detail=True, methods=['get', 'post'], url_path='entries') + def entries(self, request, pk=None): + """List or create entries for a specific nomenclature.""" + nomenclature = self.get_object() + + if request.method == 'GET': + entries = nomenclature.entries.all() + is_active = request.query_params.get('is_active') + if is_active is not None: + entries = entries.filter(is_active=is_active.lower() == 'true') + serializer = NomenclatureEntrySerializer(entries, many=True) + return Response(serializer.data) + + serializer = NomenclatureEntrySerializer( + data=request.data, + context={'nomenclature': nomenclature}, + ) + serializer.is_valid(raise_exception=True) + serializer.save(nomenclature=nomenclature) + entry_data = {**serializer.data, 'nomenclature_code': nomenclature.code} + sse_broadcast_update('nomenclature_entry', 'insert', entry_data) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class NomenclatureEntryViewSet(viewsets.ModelViewSet): + """ + API endpoint for individual nomenclature entry management. + For list/create use the nested endpoint: /api/nomenclatures/{id}/entries/ + This viewset handles retrieve/update/delete of individual entries. + """ + queryset = NomenclatureEntry.objects.select_related('nomenclature').all() + serializer_class = NomenclatureEntrySerializer + permission_classes = [IsAuthenticated] + + def get_serializer_context(self): + context = super().get_serializer_context() + if self.kwargs.get('pk'): + try: + entry = NomenclatureEntry.objects.select_related('nomenclature').get(pk=self.kwargs['pk']) + context['nomenclature'] = entry.nomenclature + except NomenclatureEntry.DoesNotExist: + pass + return context + + def perform_update(self, serializer): + instance = serializer.save() + entry_data = {**NomenclatureEntrySerializer(instance).data, 'nomenclature_code': instance.nomenclature.code} + sse_broadcast_update('nomenclature_entry', 'update', entry_data) + + def perform_destroy(self, instance): + entry_data = {**NomenclatureEntrySerializer(instance).data, 'nomenclature_code': instance.nomenclature.code} + instance.delete() + sse_broadcast_update('nomenclature_entry', 'delete', entry_data) + + +class ReportViewSet(viewsets.ModelViewSet): + """ + API endpoint for report template management. + + list: Get all reports + create: Create a new report + retrieve: Get a specific report by ID + update: Update a report + destroy: Delete a report + """ + queryset = Report.objects.select_related('created_by').all() + serializer_class = ReportSerializer + permission_classes = [IsAuthenticated] + filterset_fields = ['name'] + search_fields = ['name'] + ordering = ['-updated_at'] + + def perform_create(self, serializer): + serializer.save(created_by=self.request.user) + + def perform_update(self, serializer): + serializer.save() diff --git a/backend/nomenclatures/admin.py b/backend/nomenclatures/admin.py index 8c38f3f..447998f 100644 --- a/backend/nomenclatures/admin.py +++ b/backend/nomenclatures/admin.py @@ -1,3 +1,21 @@ from django.contrib import admin +from .models import Nomenclature, NomenclatureField, NomenclatureEntry -# Register your models here. + +class NomenclatureFieldInline(admin.TabularInline): + model = NomenclatureField + extra = 1 + + +@admin.register(Nomenclature) +class NomenclatureAdmin(admin.ModelAdmin): + list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order'] + list_filter = ['applies_to', 'kind'] + search_fields = ['code', 'name'] + inlines = [NomenclatureFieldInline] + + +@admin.register(NomenclatureEntry) +class NomenclatureEntryAdmin(admin.ModelAdmin): + list_display = ['id', '__str__', 'nomenclature', 'is_active'] + list_filter = ['nomenclature', 'is_active'] diff --git a/backend/nomenclatures/migrations/0002_alter_nomenclature_options_and_more.py b/backend/nomenclatures/migrations/0002_alter_nomenclature_options_and_more.py new file mode 100644 index 0000000..b36d064 --- /dev/null +++ b/backend/nomenclatures/migrations/0002_alter_nomenclature_options_and_more.py @@ -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"], + }, + ), + ] diff --git a/backend/nomenclatures/models.py b/backend/nomenclatures/models.py index 554f1cf..d1a99e8 100644 --- a/backend/nomenclatures/models.py +++ b/backend/nomenclatures/models.py @@ -1,13 +1,39 @@ from django.db import models -# Create your models here. + class Nomenclature(models.Model): + VEHICLE = "vehicle" + CONTAINER = "container" + APPLIES_TO_CHOICES = [ + (VEHICLE, "Vehicle"), + (CONTAINER, "Container"), + ] + + LOOKUP = "lookup" + FIELD = "field" + KIND_CHOICES = [ + (LOOKUP, "Lookup Table"), + (FIELD, "Custom Field"), + ] + code = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=255) - applies_to = models.CharField( + applies_to = models.CharField(max_length=50, choices=APPLIES_TO_CHOICES) + kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=LOOKUP) + display_field = models.CharField( max_length=50, - choices=[("vehicle", "Vehicle"), ("container", "Container")] + default="name", + blank=True, + help_text="Key of the NomenclatureField to use as dropdown label (for lookup kind)", ) + sort_order = models.IntegerField(default=0) + + class Meta: + ordering = ["sort_order", "code"] + + def __str__(self): + return f"{self.name} ({self.code})" + class NomenclatureField(models.Model): TEXT = "text" @@ -23,7 +49,42 @@ class NomenclatureField(models.Model): ] nomenclature = models.ForeignKey( - Nomenclature, on_delete=models.CASCADE + Nomenclature, + on_delete=models.CASCADE, + related_name="fields", ) key = models.CharField(max_length=50) - field_type = models.CharField(max_length=20, choices=FIELD_TYPES) \ No newline at end of file + label = models.CharField(max_length=100, blank=True) + field_type = models.CharField(max_length=20, choices=FIELD_TYPES) + required = models.BooleanField(default=False) + choices = models.JSONField( + null=True, + blank=True, + help_text='List of allowed values for choice type, e.g. ["Red","Green","Blue"]', + ) + sort_order = models.IntegerField(default=0) + + class Meta: + unique_together = [("nomenclature", "key")] + ordering = ["sort_order", "key"] + + def __str__(self): + return f"{self.nomenclature.code}.{self.key} ({self.field_type})" + + +class NomenclatureEntry(models.Model): + nomenclature = models.ForeignKey( + Nomenclature, + on_delete=models.CASCADE, + related_name="entries", + ) + data = models.JSONField(default=dict) + is_active = models.BooleanField(default=True) + + class Meta: + verbose_name_plural = "Nomenclature entries" + ordering = ["id"] + + def __str__(self): + display_key = self.nomenclature.display_field or "name" + return str(self.data.get(display_key, f"Entry #{self.pk}")) diff --git a/backend/vehicles/migrations/0002_vehicle_gross_vehicle_gross_date_vehicle_gross_user_and_more.py b/backend/vehicles/migrations/0002_vehicle_gross_vehicle_gross_date_vehicle_gross_user_and_more.py new file mode 100644 index 0000000..a42e6e8 --- /dev/null +++ b/backend/vehicles/migrations/0002_vehicle_gross_vehicle_gross_date_vehicle_gross_user_and_more.py @@ -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, + ), + ), + ] diff --git a/backend/vehicles/migrations/0003_vehicle_driver_pid_vehicle_trailer1_number_and_more.py b/backend/vehicles/migrations/0003_vehicle_driver_pid_vehicle_trailer1_number_and_more.py new file mode 100644 index 0000000..a838e97 --- /dev/null +++ b/backend/vehicles/migrations/0003_vehicle_driver_pid_vehicle_trailer1_number_and_more.py @@ -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), + ), + ] diff --git a/backend/vehicles/models.py b/backend/vehicles/models.py index 60dd5a1..0e69e39 100644 --- a/backend/vehicles/models.py +++ b/backend/vehicles/models.py @@ -1,8 +1,23 @@ +from typing import Required from django.db import models +from api.models import User + # Create your models here. class Vehicle(models.Model): vehicle_number = models.CharField(max_length=15, unique=True) + trailer1_number = models.CharField(max_length=15, null=True, blank=True) + trailer2_number = models.CharField(max_length=15, null=True, blank=True) + driver_pid = models.CharField(max_length=50, null=True, blank=True) + + tare = models.IntegerField(null=True, blank=True) + tare_date = models.DateTimeField(null=True, blank=True) + tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL) + gross = models.IntegerField(null=True, blank=True) + gross_date = models.DateTimeField(null=True, blank=True) + gross_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross', on_delete=models.SET_NULL) + + def __str__(self): return f"{self.vehicle_number}" diff --git a/frontend/src/App.js b/frontend/src/App.jsx similarity index 86% rename from frontend/src/App.js rename to frontend/src/App.jsx index 8e94601..365d01e 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import Main from './components/Main'; import ReportEditor from './components/ReportEditor/ReportEditor'; import './App.css'; import { NomenclatureProvider } from './contexts/NomenclatureContext'; +import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext'; // function MainApp() { // const [selectedPort, setSelectedPort] = useState(null); @@ -55,10 +56,12 @@ function AppContent() { return ( - - } /> - } /> - + + + } /> + } /> + + ); } diff --git a/frontend/src/components/DataDisplay.js b/frontend/src/components/DataDisplay.jsx similarity index 100% rename from frontend/src/components/DataDisplay.js rename to frontend/src/components/DataDisplay.jsx diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.jsx similarity index 79% rename from frontend/src/components/Header.js rename to frontend/src/components/Header.jsx index a8a24eb..b36c08d 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.jsx @@ -2,12 +2,14 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import ChangePasswordOverlay from './Users/ChangePasswordOverlay'; +import NomenclatureManager from './NomenclatureManager/NomenclatureManager'; import './Header.css'; function Header() { const { currentUser, logout } = useAuth(); const navigate = useNavigate(); const [showPasswordOverlay, setShowPasswordOverlay] = useState(false); + const [showNomenclatureManager, setShowNomenclatureManager] = useState(false); const getInitials = (user) => { if (user.first_name && user.last_name) { @@ -32,6 +34,13 @@ function Header() { > 📝 +

ScalesApp - Real-time Data Monitor

@@ -54,6 +63,10 @@ function Header() { {showPasswordOverlay && ( setShowPasswordOverlay(false)} /> )} + + {showNomenclatureManager && ( + setShowNomenclatureManager(false)} /> + )} ); } diff --git a/frontend/src/components/Main.css b/frontend/src/components/Main.css new file mode 100644 index 0000000..5a72ede --- /dev/null +++ b/frontend/src/components/Main.css @@ -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; + } +} diff --git a/frontend/src/components/Main.js b/frontend/src/components/Main.js deleted file mode 100644 index d711a13..0000000 --- a/frontend/src/components/Main.js +++ /dev/null @@ -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 ( -
-
-
- { vehicles.map(vehicle => { - return ( -
-

{vehicle.vehicle_number}

-
- ); - })} -
-
-
-
-

Vehicle Data

-
-
-
- -
-
-
- ); -} \ No newline at end of file diff --git a/frontend/src/components/Main.jsx b/frontend/src/components/Main.jsx new file mode 100644 index 0000000..2d17300 --- /dev/null +++ b/frontend/src/components/Main.jsx @@ -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 ( +
+
+
+ {/* Left panel - Vehicle list */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+ + {showNewForm && ( +
+ setNewVehicleNumber(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateVehicle()} + disabled={isSaving} + autoFocus + /> +
+ + +
+
+ )} + +
+ {filteredVehicles.map(v => ( +
{ setSelectedVehicleId(v.id); setError(''); }} + > + {v.vehicle_number} + {v.tare != null && ( + {v.tare} kg + )} +
+ ))} + {filteredVehicles.length === 0 && ( +
No vehicles found
+ )} +
+
+ + {/* Right panel - Vehicle detail */} +
+ {!selectedVehicle ? ( +
+ Select a vehicle or create a new one +
+ ) : ( +
+ {error &&
{error}
} + +

{selectedVehicle.vehicle_number}

+ + {/* Current reading */} +
+ +
+ {currentWeight !== null && !isNaN(currentWeight) + ? `${currentWeight} kg` + : (isConnected ? 'Waiting...' : 'Disconnected') + } +
+
+ + {/* Weighing section */} +
+ {/* Tare */} +
+
Tare
+
+ {selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'} +
+ {selectedVehicle.tare_date && ( +
+ {formatDate(selectedVehicle.tare_date)} + {selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`} +
+ )} + +
+ + {/* Gross */} +
+
Gross
+
+ {selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'} +
+ {selectedVehicle.gross_date && ( +
+ {formatDate(selectedVehicle.gross_date)} + {selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`} +
+ )} + +
+ + {/* Net */} + {netWeight !== null && ( +
+
Net
+
{netWeight} kg
+
+ )} +
+ + {/* Extra fields from nomenclatures */} + {vehicleNomenclatures.length > 0 && ( +
+

Additional Data

+
+ {vehicleNomenclatures.map(nom => ( +
+ setExtraData(prev => ({ + ...prev, + [nom.code]: val, + }))} + /> +
+ ))} +
+ +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/NomenclatureManager/NomenclatureManager.css b/frontend/src/components/NomenclatureManager/NomenclatureManager.css new file mode 100644 index 0000000..ac9c65a --- /dev/null +++ b/frontend/src/components/NomenclatureManager/NomenclatureManager.css @@ -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; + } +} diff --git a/frontend/src/components/NomenclatureManager/NomenclatureManager.jsx b/frontend/src/components/NomenclatureManager/NomenclatureManager.jsx new file mode 100644 index 0000000..ae72339 --- /dev/null +++ b/frontend/src/components/NomenclatureManager/NomenclatureManager.jsx @@ -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 ( +
+
+ {/* Header */} +
+

Nomenclatures

+ +
+ +
+ {/* Left panel - List */} +
+ + + {isLoading &&
Loading...
} + +
+ {nomenclatures.map(nom => ( +
handleSelect(nom)} + > +
+ {nom.name} + + {nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to} + +
+ +
+ ))} + + {!isLoading && nomenclatures.length === 0 && ( +
No nomenclatures yet
+ )} +
+
+ + {/* Right panel - Form */} +
+ {!form ? ( +
+ Select a nomenclature to edit or click "+ Add New" +
+ ) : ( +
+ {error &&
{error}
} + +
+ {selectedId ? 'Edit Nomenclature' : 'New Nomenclature'} +
+ + {/* Basic fields */} +
+
+ + setForm(prev => ({ ...prev, code: e.target.value }))} + placeholder="e.g. cargo_type" + disabled={isSaving} + /> +
+ +
+ + setForm(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g. Cargo Type" + disabled={isSaving} + /> +
+ +
+ + +
+ +
+ + +
+ + {form.kind === 'lookup' && ( +
+ + +
+ )} +
+ + {/* Fields section */} +
+
+

Fields

+ +
+ +
+
+ Key + Label + Type + Req + +
+ {form.fields.map((field, idx) => ( +
+ updateField(idx, 'key', e.target.value)} + placeholder="key" + disabled={isSaving} + /> + updateField(idx, 'label', e.target.value)} + placeholder="Label" + disabled={isSaving} + /> + + updateField(idx, 'required', e.target.checked)} + disabled={isSaving} + /> + +
+ ))} +
+
+ + {/* Actions */} +
+ {selectedId && ( + + )} +
+ + +
+
+
+ )} +
+
+
+
+ ); +} + +export default NomenclatureManager; diff --git a/frontend/src/components/NomenclatureUI/FormOverlay.css b/frontend/src/components/NomenclatureUI/FormOverlay.css new file mode 100644 index 0000000..b81bd14 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/FormOverlay.css @@ -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; +} diff --git a/frontend/src/components/NomenclatureUI/FormOverlay.jsx b/frontend/src/components/NomenclatureUI/FormOverlay.jsx new file mode 100644 index 0000000..0253986 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/FormOverlay.jsx @@ -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( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+ {children} +
+
+
, + document.body + ); +} diff --git a/frontend/src/components/NomenclatureUI/NomenclatureDropdown.css b/frontend/src/components/NomenclatureUI/NomenclatureDropdown.css new file mode 100644 index 0000000..1c5cce7 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/NomenclatureDropdown.css @@ -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; +} diff --git a/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx b/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx new file mode 100644 index 0000000..673e426 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx @@ -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 ( +
+ {label && } + +
+ ); + } + 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 ( +
+ +
+ + +
+ + setShowOverlay(false)} + mode="select" + initialSelection={value || null} + onSelect={handleOverlaySelect} + /> +
+ ); + } + + // FIELD type: render appropriate input + const fieldDef = nomenclature.fields?.[0]; + if (!fieldDef) return null; + + if (fieldDef.field_type === 'bool') { + return ( +
+ +
+ ); + } + + if (fieldDef.field_type === 'number') { + return ( +
+ + onChange(e.target.value === '' ? undefined : Number(e.target.value))} + disabled={disabled} + /> +
+ ); + } + + if (fieldDef.field_type === 'choice' && fieldDef.choices) { + return ( +
+ + +
+ ); + } + + // Default: text input + return ( +
+ + onChange(e.target.value || undefined)} + disabled={disabled} + /> +
+ ); +} diff --git a/frontend/src/components/NomenclatureUI/NomenclatureEntryForm.css b/frontend/src/components/NomenclatureUI/NomenclatureEntryForm.css new file mode 100644 index 0000000..22f2577 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/NomenclatureEntryForm.css @@ -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; +} diff --git a/frontend/src/components/NomenclatureUI/NomenclatureEntryForm.jsx b/frontend/src/components/NomenclatureUI/NomenclatureEntryForm.jsx new file mode 100644 index 0000000..854f54a --- /dev/null +++ b/frontend/src/components/NomenclatureUI/NomenclatureEntryForm.jsx @@ -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 ( +
+ {sortedFields.map((field, idx) => ( +
+ + + {field.field_type === 'bool' ? ( + + ) : field.field_type === 'number' ? ( + 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 ? ( + + ) : ( + handleChange(field.key, e.target.value)} + onFocus={handleAutoSelect} + onDragStart={preventDrag} + disabled={isSaving} + className={errors[field.key] ? 'nui-entry-form-input-error' : ''} + /> + )} + + {errors[field.key] && ( + {errors[field.key]} + )} +
+ ))} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css b/frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css new file mode 100644 index 0000000..1fa4d56 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css @@ -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; +} diff --git a/frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.jsx b/frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.jsx new file mode 100644 index 0000000..37248c1 --- /dev/null +++ b/frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.jsx @@ -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( +
+
e.stopPropagation()}> + {/* Header */} +
+

{nomenclature.name}

+ +
+ + {/* Toolbar */} +
+ setSearchTerm(e.target.value)} + /> + +
+ + {error &&
{error}
} + + {/* Table */} +
+ + + + + {sortedFields.map(f => ( + + ))} + + + + + + {filteredEntries.length === 0 ? ( + + + + ) : ( + filteredEntries.map(entry => ( + handleRowClick(entry)} + onDoubleClick={() => handleRowDoubleClick(entry)} + > + + {sortedFields.map(f => ( + + ))} + + + + )) + )} + +
ID{f.label || f.key}Active
+ {searchTerm ? 'No entries match your search' : 'No entries yet. Click "+ Add New" to create one.'} +
{entry.id} + {f.field_type === 'bool' + ? (entry.data?.[f.key] ? 'Yes' : 'No') + : (entry.data?.[f.key] ?? '') + } + + { 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'} + + + +
+
+ + {/* Footer */} +
+
+ {filteredEntries.length} {filteredEntries.length === 1 ? 'entry' : 'entries'} + {searchTerm && ` (filtered)`} +
+
+ {selectedEntry && ( + <> + + + + )} + {mode === 'select' && ( + + )} +
+
+
+ + {/* Form overlay (renders on top, z-index 13000) */} + setShowForm(false)} + title={formMode === 'edit' ? `Edit ${nomenclature.name} Entry` : `Add New ${nomenclature.name} Entry`} + > + setShowForm(false)} + isSaving={isSaving} + /> + +
, + document.body + ); +} diff --git a/frontend/src/components/ProtectedRoute.js b/frontend/src/components/ProtectedRoute.jsx similarity index 100% rename from frontend/src/components/ProtectedRoute.js rename to frontend/src/components/ProtectedRoute.jsx diff --git a/frontend/src/components/ReportEditor/BandContext.js b/frontend/src/components/ReportEditor/BandContext.jsx similarity index 100% rename from frontend/src/components/ReportEditor/BandContext.js rename to frontend/src/components/ReportEditor/BandContext.jsx diff --git a/frontend/src/components/ReportEditor/BandElement.js b/frontend/src/components/ReportEditor/BandElement.jsx similarity index 100% rename from frontend/src/components/ReportEditor/BandElement.js rename to frontend/src/components/ReportEditor/BandElement.jsx diff --git a/frontend/src/components/ReportEditor/CharacterPalette.js b/frontend/src/components/ReportEditor/CharacterPalette.jsx similarity index 100% rename from frontend/src/components/ReportEditor/CharacterPalette.js rename to frontend/src/components/ReportEditor/CharacterPalette.jsx diff --git a/frontend/src/components/ReportEditor/ConfigPanel.js b/frontend/src/components/ReportEditor/ConfigPanel.jsx similarity index 100% rename from frontend/src/components/ReportEditor/ConfigPanel.js rename to frontend/src/components/ReportEditor/ConfigPanel.jsx diff --git a/frontend/src/components/ReportEditor/DBTextField.js b/frontend/src/components/ReportEditor/DBTextField.jsx similarity index 100% rename from frontend/src/components/ReportEditor/DBTextField.js rename to frontend/src/components/ReportEditor/DBTextField.jsx diff --git a/frontend/src/components/ReportEditor/DataContext.js b/frontend/src/components/ReportEditor/DataContext.jsx similarity index 100% rename from frontend/src/components/ReportEditor/DataContext.js rename to frontend/src/components/ReportEditor/DataContext.jsx diff --git a/frontend/src/components/ReportEditor/EditorCanvas.js b/frontend/src/components/ReportEditor/EditorCanvas.jsx similarity index 100% rename from frontend/src/components/ReportEditor/EditorCanvas.js rename to frontend/src/components/ReportEditor/EditorCanvas.jsx diff --git a/frontend/src/components/ReportEditor/FrameElement.js b/frontend/src/components/ReportEditor/FrameElement.jsx similarity index 100% rename from frontend/src/components/ReportEditor/FrameElement.js rename to frontend/src/components/ReportEditor/FrameElement.jsx diff --git a/frontend/src/components/ReportEditor/HorizontalLine.js b/frontend/src/components/ReportEditor/HorizontalLine.jsx similarity index 100% rename from frontend/src/components/ReportEditor/HorizontalLine.js rename to frontend/src/components/ReportEditor/HorizontalLine.jsx diff --git a/frontend/src/components/ReportEditor/ObjectInspector.js b/frontend/src/components/ReportEditor/ObjectInspector.jsx similarity index 100% rename from frontend/src/components/ReportEditor/ObjectInspector.js rename to frontend/src/components/ReportEditor/ObjectInspector.jsx diff --git a/frontend/src/components/ReportEditor/ReportEditor.css b/frontend/src/components/ReportEditor/ReportEditor.css index 952351d..543c09b 100644 --- a/frontend/src/components/ReportEditor/ReportEditor.css +++ b/frontend/src/components/ReportEditor/ReportEditor.css @@ -10,3 +10,146 @@ flex: 1; overflow: hidden; } + +/* Load Report Dialog */ +.load-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.load-dialog { + background: white; + border-radius: 8px; + width: 500px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.load-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; +} + +.load-dialog-header h2 { + margin: 0; + font-size: 18px; + color: #333; +} + +.load-dialog-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; + padding: 0; + line-height: 1; +} + +.load-dialog-close:hover { + color: #333; +} + +.load-dialog-actions { + padding: 12px 20px; + border-bottom: 1px solid #eee; +} + +.load-dialog-new-btn { + padding: 8px 16px; + background: #667eea; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} + +.load-dialog-new-btn:hover { + background: #5a6fd6; +} + +.load-dialog-content { + flex: 1; + overflow-y: auto; + padding: 12px 0; +} + +.load-dialog-loading, +.load-dialog-empty { + padding: 40px 20px; + text-align: center; + color: #999; +} + +.load-dialog-list { + list-style: none; + margin: 0; + padding: 0; +} + +.load-dialog-item { + display: flex; + align-items: center; + padding: 12px 20px; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background 0.2s; +} + +.load-dialog-item:hover { + background: #f8f8f8; +} + +.load-dialog-item--current { + background: #e8f0ff; +} + +.load-dialog-item--current:hover { + background: #dde8ff; +} + +.load-dialog-item-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.load-dialog-item-name { + font-weight: 500; + color: #333; +} + +.load-dialog-item-date { + font-size: 12px; + color: #999; +} + +.load-dialog-item-delete { + background: none; + border: none; + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + opacity: 0.5; + transition: opacity 0.2s; +} + +.load-dialog-item-delete:hover { + opacity: 1; +} diff --git a/frontend/src/components/ReportEditor/ReportEditor.js b/frontend/src/components/ReportEditor/ReportEditor.jsx similarity index 61% rename from frontend/src/components/ReportEditor/ReportEditor.js rename to frontend/src/components/ReportEditor/ReportEditor.jsx index 7c0fb0e..8e3da09 100644 --- a/frontend/src/components/ReportEditor/ReportEditor.js +++ b/frontend/src/components/ReportEditor/ReportEditor.jsx @@ -6,6 +6,7 @@ import EditorCanvas from './EditorCanvas'; import ConfigPanel from './ConfigPanel'; import CharacterPalette from './CharacterPalette'; import ObjectInspector from './ObjectInspector'; +import api from '../../services/api'; import './ReportEditor.css'; function ReportEditorContent() { @@ -25,6 +26,13 @@ function ReportEditorContent() { const [selectedChar, setSelectedChar] = useState('─'); const [showCharacterPalette, setShowCharacterPalette] = useState(false); + // Save/Load state + const [reportId, setReportId] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [showLoadDialog, setShowLoadDialog] = useState(false); + const [savedReports, setSavedReports] = useState([]); + const [loadingReports, setLoadingReports] = useState(false); + const handleAddElement = (element) => { setReport(prev => { // Check if the new element is positioned inside a band @@ -178,6 +186,112 @@ function ReportEditorContent() { })); }; + const handleReportNameChange = (name) => { + setReport(prev => ({ ...prev, name })); + }; + + // Save report to backend + const handleSave = async () => { + if (!report.name.trim()) { + alert('Please enter a report name'); + return; + } + + setIsSaving(true); + try { + const payload = { + name: report.name, + page_width: report.pageWidth, + page_height: report.pageHeight, + api_endpoint: report.apiEndpoint, + elements: report.elements + }; + + if (reportId) { + // Update existing report + await api.put(`/api/reports/${reportId}/`, payload); + } else { + // Create new report + const res = await api.post('/api/reports/', payload); + setReportId(res.data.id); + } + alert('Report saved successfully'); + } catch (err) { + console.error('Failed to save report:', err); + alert('Failed to save report: ' + (err.response?.data?.detail || err.message)); + } finally { + setIsSaving(false); + } + }; + + // Load reports list for dialog + const handleLoadClick = async () => { + setLoadingReports(true); + setShowLoadDialog(true); + try { + const res = await api.get('/api/reports/'); + setSavedReports(res.data.results || res.data || []); + } catch (err) { + console.error('Failed to load reports list:', err); + alert('Failed to load reports list'); + } finally { + setLoadingReports(false); + } + }; + + // Load a specific report + const handleLoadReport = async (id) => { + try { + const res = await api.get(`/api/reports/${id}/`); + const data = res.data; + setReport({ + name: data.name, + pageWidth: data.page_width, + pageHeight: data.page_height, + apiEndpoint: data.api_endpoint || '', + elements: data.elements || [] + }); + setReportId(data.id); + setSelectedElementIds([]); + setShowLoadDialog(false); + } catch (err) { + console.error('Failed to load report:', err); + alert('Failed to load report'); + } + }; + + // Delete a report + const handleDeleteReport = async (id) => { + if (!window.confirm('Are you sure you want to delete this report?')) return; + try { + await api.delete(`/api/reports/${id}/`); + setSavedReports(prev => prev.filter(r => r.id !== id)); + if (reportId === id) { + setReportId(null); + } + } catch (err) { + console.error('Failed to delete report:', err); + alert('Failed to delete report'); + } + }; + + // New report + const handleNewReport = () => { + if (report.elements.length > 0) { + if (!window.confirm('Discard current report and create a new one?')) return; + } + setReport({ + name: 'Untitled Report', + pageWidth: 80, + pageHeight: 66, + apiEndpoint: '', + elements: [] + }); + setReportId(null); + setSelectedElementIds([]); + setShowLoadDialog(false); + }; + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { @@ -217,6 +331,11 @@ function ReportEditorContent() { previewMode={previewMode} onTogglePreview={handleTogglePreview} onConfigureAPI={() => setShowConfigPanel(true)} + onSave={handleSave} + onLoad={handleLoadClick} + reportName={report.name} + onReportNameChange={handleReportNameChange} + isSaving={isSaving} />
)} + + {/* Load Report Dialog */} + {showLoadDialog && ( +
setShowLoadDialog(false)}> +
e.stopPropagation()}> +
+

Load Report

+ +
+
+ +
+
+ {loadingReports ? ( +
Loading...
+ ) : savedReports.length === 0 ? ( +
No saved reports
+ ) : ( +
    + {savedReports.map(r => ( +
  • +
    handleLoadReport(r.id)}> + {r.name} + + {new Date(r.updated_at).toLocaleDateString()} + +
    + +
  • + ))} +
+ )} +
+
+
+ )}
); } diff --git a/frontend/src/components/ReportEditor/ResizeHandles.js b/frontend/src/components/ReportEditor/ResizeHandles.jsx similarity index 100% rename from frontend/src/components/ReportEditor/ResizeHandles.js rename to frontend/src/components/ReportEditor/ResizeHandles.jsx diff --git a/frontend/src/components/ReportEditor/SymbolElement.js b/frontend/src/components/ReportEditor/SymbolElement.jsx similarity index 100% rename from frontend/src/components/ReportEditor/SymbolElement.js rename to frontend/src/components/ReportEditor/SymbolElement.jsx diff --git a/frontend/src/components/ReportEditor/TextField.js b/frontend/src/components/ReportEditor/TextField.jsx similarity index 100% rename from frontend/src/components/ReportEditor/TextField.js rename to frontend/src/components/ReportEditor/TextField.jsx diff --git a/frontend/src/components/ReportEditor/Toolbar.css b/frontend/src/components/ReportEditor/Toolbar.css index 2f3dd8e..4dd6bbf 100644 --- a/frontend/src/components/ReportEditor/Toolbar.css +++ b/frontend/src/components/ReportEditor/Toolbar.css @@ -50,3 +50,33 @@ border-color: rgba(255, 255, 255, 0.5); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } + +.toolbar-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.toolbar-group--file { + margin-left: auto; +} + +.toolbar-report-name { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + font-size: 14px; + width: 200px; + color: #333; +} + +.toolbar-report-name::placeholder { + color: #999; +} + +.toolbar-report-name:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.6); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2); +} diff --git a/frontend/src/components/ReportEditor/Toolbar.js b/frontend/src/components/ReportEditor/Toolbar.jsx similarity index 79% rename from frontend/src/components/ReportEditor/Toolbar.js rename to frontend/src/components/ReportEditor/Toolbar.jsx index 7f9e82b..63e698a 100644 --- a/frontend/src/components/ReportEditor/Toolbar.js +++ b/frontend/src/components/ReportEditor/Toolbar.jsx @@ -8,7 +8,12 @@ function Toolbar({ onBorderStyleChange, previewMode = false, onTogglePreview, - onConfigureAPI + onConfigureAPI, + onSave, + onLoad, + reportName, + onReportNameChange, + isSaving = false }) { return (
@@ -107,6 +112,32 @@ function Toolbar({ )}
+
+
+ onReportNameChange && onReportNameChange(e.target.value)} + placeholder="Report name" + title="Report name" + /> + + +
); } diff --git a/frontend/src/components/ReportEditor/VerticalLine.js b/frontend/src/components/ReportEditor/VerticalLine.jsx similarity index 100% rename from frontend/src/components/ReportEditor/VerticalLine.js rename to frontend/src/components/ReportEditor/VerticalLine.jsx diff --git a/frontend/src/components/ReportEditor/components/ElementWrapper.js b/frontend/src/components/ReportEditor/components/ElementWrapper.jsx similarity index 100% rename from frontend/src/components/ReportEditor/components/ElementWrapper.js rename to frontend/src/components/ReportEditor/components/ElementWrapper.jsx diff --git a/frontend/src/components/ReportEditor/hooks/useElementDrag.js b/frontend/src/components/ReportEditor/hooks/useElementDrag.jsx similarity index 100% rename from frontend/src/components/ReportEditor/hooks/useElementDrag.js rename to frontend/src/components/ReportEditor/hooks/useElementDrag.jsx diff --git a/frontend/src/components/ReportEditor/hooks/useElementSelection.js b/frontend/src/components/ReportEditor/hooks/useElementSelection.jsx similarity index 100% rename from frontend/src/components/ReportEditor/hooks/useElementSelection.js rename to frontend/src/components/ReportEditor/hooks/useElementSelection.jsx diff --git a/frontend/src/components/ReportEditor/models/Element.js b/frontend/src/components/ReportEditor/models/Element.jsx similarity index 100% rename from frontend/src/components/ReportEditor/models/Element.js rename to frontend/src/components/ReportEditor/models/Element.jsx diff --git a/frontend/src/components/Users/ChangePasswordOverlay.js b/frontend/src/components/Users/ChangePasswordOverlay.jsx similarity index 100% rename from frontend/src/components/Users/ChangePasswordOverlay.js rename to frontend/src/components/Users/ChangePasswordOverlay.jsx diff --git a/frontend/src/components/Users/Login.js b/frontend/src/components/Users/Login.jsx similarity index 100% rename from frontend/src/components/Users/Login.js rename to frontend/src/components/Users/Login.jsx diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.jsx similarity index 100% rename from frontend/src/contexts/AuthContext.js rename to frontend/src/contexts/AuthContext.jsx diff --git a/frontend/src/contexts/NomenclatureContext.jsx b/frontend/src/contexts/NomenclatureContext.jsx index b4f9886..96baecc 100644 --- a/frontend/src/contexts/NomenclatureContext.jsx +++ b/frontend/src/contexts/NomenclatureContext.jsx @@ -358,6 +358,18 @@ export const NomenclatureProvider = ({ children }) => { const { object, operation, data } = message; + // Handle nomenclature definition and entry updates - notify all subscribers + if (object === 'nomenclature' || object === 'nomenclature_entry') { + subscribersRef.current.forEach(callback => { + try { + callback(message); + } catch (error) { + console.error('Error in nomenclature entry update subscriber:', error); + } + }); + return; + } + // Handle invoice updates - notify all subscribers if (object === 'invoice' || object === 'notice') { subscribersRef.current.forEach(callback => { diff --git a/frontend/src/contexts/NomenclatureDataContext.jsx b/frontend/src/contexts/NomenclatureDataContext.jsx new file mode 100644 index 0000000..ca27f7e --- /dev/null +++ b/frontend/src/contexts/NomenclatureDataContext.jsx @@ -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 ( + + {children} + + ); +}; diff --git a/frontend/src/index.js b/frontend/src/index.jsx similarity index 100% rename from frontend/src/index.js rename to frontend/src/index.jsx diff --git a/run_comport_reader.bat b/run_comport_reader.bat new file mode 100644 index 0000000..4b5d8dd --- /dev/null +++ b/run_comport_reader.bat @@ -0,0 +1 @@ +python .\serial_bridge\app.py \ No newline at end of file diff --git a/run_test.bat b/run_test.bat new file mode 100644 index 0000000..7cdb167 --- /dev/null +++ b/run_test.bat @@ -0,0 +1 @@ +python .\test_comport_writer\test_writer.py \ No newline at end of file