# Quick-Fill Combinations for Vehicle Additional Data ## Context Trucks frequently arrive one after another carrying the same cargo, from the same speditor, via the same agent. Today the operator re-selects every dropdown in the "Additional Data" section of the vehicle edit form for each truck, even when nothing changed from the previous one. This adds a helper table above those fields listing previously-used *combinations* of the lookup values. One click fills all the dropdowns. Rows are created automatically when a vehicle's extra data is saved, and the user deletes rows they no longer need. **Decisions made with the user:** - A "combination" = only `kind='lookup'` nomenclature values. Free-text/number/bool fields are excluded so one-off notes don't pollute the table. - Re-using an existing combination bumps its `last_used` and increments `use_count`. No duplicate rows. - Rows are shared by all users (global, not per-user). - Rows persist indefinitely — no cap, no auto-trim. Manual delete only. - Table renders inline, always visible, directly above the existing fields, in **both** the new-vehicle and edit forms. --- ## Backend ### 1. Model — `backend/vehicles/models.py` Lives in `vehicles/` because it's a usage history of `VehicleExtra.data`, a domain model. (`api/` holds the API layer; `vehicles/views.py|serializers.py|urls.py` are dead, broken, unrouted code — do not touch them.) Dedup uses a canonical hash, not JSONField equality: on SQLite a `JSONField` exact-match compares the serialized string, so `{"A":1,"B":2}` ≠ `{"B":2,"A":1}`. A `unique=True` SHA-256 column is backend-agnostic and O(1). Add `import hashlib` / `import json` at the top, then: ```python def combination_hash(data): """Order-independent canonical hash of a {nomenclature_code: entry_id} dict.""" canonical = json.dumps(data, sort_keys=True, separators=(',', ':')) return hashlib.sha256(canonical.encode('utf-8')).hexdigest() class ExtraCombination(models.Model): """A previously-used combination of lookup nomenclature values for a vehicle's extra data.""" data = models.JSONField(default=dict) data_hash = models.CharField(max_length=64, unique=True, editable=False) use_count = models.IntegerField(default=1) last_used = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-last_used"] def save(self, *args, **kwargs): self.data_hash = combination_hash(self.data) super().save(*args, **kwargs) def __str__(self): return f"{self.data} (x{self.use_count})" ``` Computing the hash inside `save()` means it can never drift from `data`. `auto_now_add` (not `auto_now`) + an explicit bump in the recorder keeps the timestamp meaning "last used", not "last saved". Migration: `python manage.py makemigrations vehicles` → `0005_extracombination.py` (latest is currently `0004_`). ### 2. Serializer — `backend/api/serializers.py` Extract the existing validation at [api/serializers.py:85-123](backend/api/serializers.py#L85-L123) into a module-level function with a `lookup_only` flag, so both serializers share it. Behaviour for the existing path is bit-identical. ```python def validate_vehicle_extra_data(value, lookup_only=False): """Validate a {nomenclature_code: value} dict against vehicle nomenclatures. lookup_only=True rejects any key whose nomenclature is not kind='lookup'. """ ``` Body is the current `validate_data` verbatim, with one insert in the `Nomenclature.FIELD` branch: ```python elif nom.kind == Nomenclature.FIELD: if lookup_only: errors[key] = "Only lookup nomenclatures are allowed here." continue field_def = nom.fields.first() # ...rest unchanged ``` `VehicleExtraSerializer.validate_data` becomes `return validate_vehicle_extra_data(value)`. New serializer (after `VehicleSerializer`): ```python class ExtraCombinationSerializer(serializers.ModelSerializer): class Meta: model = ExtraCombination fields = ['id', 'data', 'use_count', 'last_used'] read_only_fields = ['id', 'use_count', 'last_used'] def validate_data(self, value): if not value: raise serializers.ValidationError("Combination cannot be empty.") return validate_vehicle_extra_data(value, lookup_only=True) ``` **Returns raw `{code: entry_id}` — no denormalized labels.** The frontend already has `getDisplayValue(code, entryId)` backed by an SSE-live cache, so labels update in realtime when an entry is renamed. Server-side labels would be a stale snapshot requiring re-broadcast of every row on every entry edit. ### 3. Recorder + ViewSet — `backend/api/views.py` Imports: add `mixins` to the DRF import, `ExtraCombination` and `combination_hash` to the `vehicles.models` import, `ExtraCombinationSerializer` to the serializer import. Helper above `VehicleViewSet` (next to `_maybe_finalize`, matching file convention): ```python def _record_extra_combination(extra_data): """Record the lookup-only projection of a vehicle's extra data as a reusable combination.""" if not extra_data: return lookup_codes = set( Nomenclature.objects.filter(applies_to='vehicle', kind=Nomenclature.LOOKUP) .values_list('code', flat=True) ) combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None} if not combo: return existing, created = ExtraCombination.objects.get_or_create( data_hash=combination_hash(combo), defaults={'data': combo} ) if not created: existing.use_count += 1 existing.last_used = timezone.now() existing.save() action = 'created' if created else 'updated' sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data) ``` `get_or_create` (rather than `filter().first()` + `create()`) closes the race where two users save the same brand-new combination simultaneously — otherwise one gets an `IntegrityError` 500 *after* the vehicle was already saved. Two guards matter: no extra at all, and extra containing only `kind='field'` values (the common "user typed a one-off note" case). Neither should create a row. ViewSet after `VehicleViewSet`: ```python class ExtraCombinationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet): """ API endpoint for previously-used combinations of vehicle extra (lookup) data. Rows are created/bumped automatically when a vehicle's extra data is saved. """ queryset = ExtraCombination.objects.all() serializer_class = ExtraCombinationSerializer permission_classes = [IsAuthenticated] ordering = ['-last_used'] def perform_destroy(self, instance): data = ExtraCombinationSerializer(instance).data instance.delete() sse_broadcast_update('extra_combination', 'deleted', data) ``` No create/update endpoints — rows only ever appear as a side effect of saving a vehicle. Route in [api/urls.py](backend/api/urls.py): ```python router.register(r'extra-combinations', views.ExtraCombinationViewSet, basename='extra-combination') ``` ### 4. Where recording fires — `VehicleViewSet` **Backend, in `perform_create`/`perform_update`.** This is the layer that already owns side effects of a successful vehicle mutation (`sse_broadcast_update`), it covers both required paths (create-with-extra, and the `handleSaveExtra` PATCH which routes through `partial_update` → `perform_update`), and it can't be forgotten by frontend code. Rejected: a frontend POST (two round-trips, fails independently, must be duplicated, wrong trust boundary); a `post_save` signal (would fire on fixtures, migrations, shell saves and admin edits, broadcasting SSE with no request context — and the project has zero signals today). One line added to each, at [api/views.py:161-169](backend/api/views.py#L161-L169): ```python _record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None) ``` `hasattr` matches the existing style at [api/serializers.py:162](backend/api/serializers.py#L162); `extra` is a reverse OneToOne that raises `DoesNotExist` when absent. `set_tare`/`set_gross` bypass `perform_update` and don't touch extra data — leave them alone. --- ## Frontend ### 5. New component — `frontend/src/components/ExtraCombinationsTable.jsx` + `.css` Sits in `components/` next to `Main.jsx` (it's a Main-screen helper, not a nomenclature UI primitive). Props: `{ nomenclatures, onApply }` — `nomenclatures` is the already-sorted `vehicleNomenclatures` from [Main.jsx:28](frontend/src/components/Main.jsx#L28) so columns line up with the dropdowns below; `onApply(data)` gets the raw dict. **Data + realtime: local state + fetch on mount + `subscribeToUpdates`.** Extending `NOMENCLATURES_CONFIG` was rejected — that machinery sorts by `name`/`id` (not `-last_used`) and its handler has no `delete` branch, so it'd mean modifying shared load-bearing code for one consumer. This needs **one token** added to [NomenclatureContext.jsx:362](frontend/src/contexts/NomenclatureContext.jsx#L362), since unknown objects currently hit `console.warn` and get dropped: ```jsx if (object === 'nomenclature' || object === 'nomenclature_entry' || object === 'extra_combination') { ``` Strictly additive — existing subscribers already filter on `message.object` before acting. **Stale-closure hazard:** `subscribeToUpdates` is not `useCallback`'d ([NomenclatureContext.jsx:344](frontend/src/contexts/NomenclatureContext.jsx#L344)), so a `useEffect` depending on it re-subscribes every provider render. Hold it in a ref and run the effect with `[]` deps. Leave a one-line comment so nobody "fixes" it back. Key logic: ```jsx const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup'); const load = useCallback(async () => { const res = await api.get('/api/extra-combinations/'); setCombinations(res.data.results || res.data); // PAGE_SIZE=100 envelope }, []); // SSE if (message.object !== 'extra_combination') return; setCombinations(prev => operation === 'delete' ? prev.filter(c => c.id !== data.id) : [data, ...prev.filter(c => c.id !== data.id)]); // most recent first ``` Table: one `` per lookup nomenclature (`nom.name`) plus a narrow actions column; rows call `onApply(combo.data)`; each row has a `×` delete button with `e.stopPropagation()` and a `window.confirm`. Cells resolve via `getDisplayValue(nom.code, entryId)`, rendering `#` in muted italic when an id no longer resolves. Returns `null` if there are no lookup nomenclatures. CSS follows the `nui-mgmt-*` table conventions from [NomenclatureManagementOverlay.css](frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css), scaled down for inline use: sticky header on `#f8f9fa`, `1px solid #dee2e6` row borders, `#f0f0ff` hover, `max-height: 180px` + `overflow-y: auto`, `overflow-x: auto` with `white-space: nowrap`, uppercase 0.75rem headers, 6px radii, `#dc3545` delete hover. ### 6. `Main.jsx` — apply handler + placement ```jsx // Apply a saved combination - merge so non-lookup field values are preserved const handleApplyCombination = (data) => { setExtraData(prev => ({ ...prev, ...data })); }; ``` **Merge, not replace.** `data` only contains lookup codes (enforced server-side), so spreading over `prev` preserves every free-text/number/bool value the user already typed. Lookup fields absent from the combination keep their current value rather than being cleared — the safer default, never silently destroying input. Placement inside the existing edit-form `.extra-section` at [Main.jsx:417-442](frontend/src/components/Main.jsx#L417-L442), directly above `.extra-fields`: ```jsx

Additional Data

``` **Both forms.** The same table renders in the new-vehicle form and the edit form, each paired with its own state via a sibling handler: ```jsx const handleApplyCombinationNew = (data) => { setNewExtraData(prev => ({ ...prev, ...data })); }; ``` --- ## Edge cases | Case | Behaviour | Action | |---|---|---| | Entry **deactivated** after being saved in a combination | `getDisplayValue` searches all entries, so the label still renders. Save fails loudly with `Entry ID N not found`, caught by the existing `handleSaveExtra` error path. | None. Deactivation is soft and reversible; hiding rows would make them vanish and reappear. | | Entry **hard-deleted** | Cell flips live to muted italic `#` (SSE removes it from the cache). | Render the marker so the broken row is visible and deletable — hiding it would remove the only way to clean it up. | | **Nomenclature deleted** | Its column disappears from the table; the orphan key stays in `data`, unrendered. Clicking such a row would fail validation on save with `Unknown nomenclature code`. | None. Admin-rare, self-resolving on the next fresh save. A cleanup signal would be speculative machinery. | | **Validation failure on click-apply** | Impossible — clicking is local `setState` only, no network. Errors surface at save via the existing `.vehicle-error` banner. | None. Don't special-case; this is how every other extra-data error already behaves. | | **Concurrent identical saves** | Handled by `get_or_create` (§3). | Done. | | **Empty projection** | Two guards in the recorder + serializer rejects `{}`. | Done. | --- ## Verification Backend on 8002 via `run_server.bat`; frontend via `npm start` in `frontend/` (port 5172). Preflight: confirm `frontend/.env` sets `REACT_APP_API_URL=http://localhost:8002` — [NomenclatureContext.jsx:3](frontend/src/contexts/NomenclatureContext.jsx#L3) defaults to `:8000`, so a wrong value makes SSE silently dead while REST still works through the proxy. Confirm `http://localhost:5172` is in `CORS_ALLOWED_ORIGINS`. ```bat cd backend && ..\.venv\Scripts\activate python manage.py makemigrations vehicles && python manage.py migrate python manage.py showmigrations vehicles :: expect [X] 0005_extracombination ``` Needs ≥2 vehicle lookup nomenclatures with active entries (e.g. `CARGO`, `SPEDITOR`) and one `kind='field'` text nomenclature (e.g. `NOTE`) for step 5. 1. **Empty state** — select a vehicle; "Previously Used" shows *No saved combinations yet* with correct columns. 2. **First save creates a row** — Cargo=Coal, Speditor=ACME → Save Extra Data. Row appears with no page reload. 3. **Dedup** — same combination on a *different* vehicle → still exactly one row. Verify: ```bat python manage.py shell -c "from vehicles.models import ExtraCombination; [print(c.id, c.data, c.use_count, c.last_used) for c in ExtraCombination.objects.all()]" ``` Expect `use_count=2`, fresh `last_used`. 4. **Click-apply** — clear both dropdowns, click the row → both populate. Save → one row, `use_count=3`. 5. **Merge preserves free-text** *(most important regression check)* — type text into `NOTE`, click a combination row. **The note must survive**, and `NOTE` must have no column in the table. 6. **Ordering** — save a second distinct combination; it appears above. Re-save the first → it returns to the top. 7. **Delete over SSE** — open a second browser tab first, then delete a row via `×`. It must disappear in **both** tabs (this is the path `NomenclatureContext` doesn't natively support, so most likely to break). 8. **New-vehicle path** — create a vehicle with extra data via "+ New Vehicle" → row created (verifies `perform_create`). 9. **Empty projection** — create a vehicle with only `NOTE` filled → **no new row**; confirm count unchanged in the shell. 10. **Stale id** — hard-delete a referenced entry via the `…` overlay. Cell flips to `#` live; clicking + saving shows the `Entry ID N not found` error; `×` removes the row. 11. **Key-order independence** — ```bat python manage.py shell -c "from vehicles.models import combination_hash; print(combination_hash({'A':1,'B':2}) == combination_hash({'B':2,'A':1}))" ``` Expect `True`. 12. **API surface** — `GET /api/extra-combinations/` returns the paginated envelope ordered by `-last_used`; `POST` returns **405**. --- ## Files changed **Backend** - `backend/vehicles/models.py` — imports, `combination_hash()`, `ExtraCombination` - `backend/vehicles/migrations/0005_extracombination.py` — generated - `backend/api/serializers.py` — extract `validate_vehicle_extra_data()`, slim `VehicleExtraSerializer.validate_data`, add `ExtraCombinationSerializer` - `backend/api/views.py` — imports, `_record_extra_combination()`, 1 line each in `perform_create`/`perform_update`, `ExtraCombinationViewSet` - `backend/api/urls.py` — 1 router line **Frontend** - `frontend/src/components/ExtraCombinationsTable.jsx` — new - `frontend/src/components/ExtraCombinationsTable.css` — new - `frontend/src/components/Main.jsx` — import, `handleApplyCombination` + `handleApplyCombinationNew`, JSX insert in both forms - `frontend/src/contexts/NomenclatureContext.jsx` — 1 token at line 362 Untouched: `vehicles/views.py`, `vehicles/serializers.py`, `vehicles/urls.py` (dead code), `nomenclatures/`, `Main.css`.