Compare commits

..

21 Commits

Author SHA1 Message Date
gitea 6cf4c74845 made the long waited animation when add new truck or truck is completed. mouse over the list prevents the list from changing, only the animations work. moved top menu buttons to the right sidebar to free some vertical space. license text on button hover, not fixed above 2026-10-03 16:40:36 +03:00
gitea 4b0739ba06 added css classes for the fonts and colors, fixed reports strango colors inheritance 2026-09-23 20:42:50 +03:00
gitea 32aa2eeda9 Keep report paper text black in every theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:35:43 +03:00
gitea 82f3a674a3 Put the report editor chrome on design tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:18:29 +03:00
gitea 68cf541bbe Move the hardcoded screens and user manager onto tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:15:30 +03:00
gitea ee599978da Use type and spacing tokens on the themed screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:10:52 +03:00
gitea 63056f5b42 Add typography and spacing design tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:09:39 +03:00
gitea 6f614270e7 added print, download as pdf. no puppeteer, we'll see 2026-09-22 22:06:06 +03:00
gitea 122480cd46 Add the A4 report designer and fix band layout
Brings in the A4 (millimetre) report designer alongside the existing
matrix editor, migrates the frontend from react-scripts to Vite/Vitest,
and fixes four defects found while using the A4 band tools.

Layout — layout.js now implements the standard banded-report model:
everything prints where it was drawn, and a band growing per data row is
the only thing that moves anything. Previously `paginate` re-flowed every
band from the top margin, so a band placed halfway down the page
previewed at the top, and `header` was treated as a page header and
hoisted above the statics drawn before it. A header is a COLUMN header:
it prints in place and repeats only on a continuation page. Statics now
take part too — one drawn below a band keeps its drawn gap as that band
grows, and follows onto the next page rather than vanishing when pushed
past the bottom margin. The displacement is keyed on the band's design
bottom, not on an instance's laid-out y: every row of a band shares one
design position, so keying on the instance measured the gap from row 1
and let the static overlap rows 2..n.

Band children — a child's x is relative to its band, as its y already
was. layout() emitted it raw, so a field printed measured from the paper
edge instead of the band's left margin. Instances now carry bandX.

Band placement — a band's x comes from the page margins, so creation
takes only the click's Y (the factory's x was being overwritten by the
spread order) and dragging is locked to the vertical axis. Bands render
as a strip, with top/bottom rules only, matching the matrix editor.
fromApiReport reflows on load so reports saved with a stray x straighten
themselves. Matrix bands are constrained to vertical too.

Drag lifecycle — useElementDrag re-subscribed its document listeners on
every render during a drag, because the effect depended on an unmemoized
onDrag. Each re-subscribe removed the mouseup listener before adding the
next, measured as add/remove/add/remove/add across two mousemoves, and a
release landing in that gap was lost: the element kept following the
cursor until the next click. Snapping made it routine, since a held
guide stops x/y changing and leaves the guide overlay's render sitting
between the move and the release. onDrag and dragData now go through
refs so the effect depends on [isDragging] alone.

Note: the matrix editor has no test coverage, so its band constraint is
verified by reading only. The A4 suite is 201 passing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:52:20 +03:00
gitea ddc128dba4 Drop BACKEND_URL from the documented serial bridge config
The setting no longer exists: it was removed along with the bridge's unused
Django client. The surrounding section already states the bridge is
independent of Django, so this line was the one place in the file implying
otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:49:57 +03:00
gitea edfe2f1bfe Remove the serial bridge's unused Django client
The bridge reads the COM port and streams SSE to the frontend; it never
talks to Django. backend_client.py (a Django API client) and config.py
(which held BACKEND_URL / API_ENDPOINT) were imported by nothing - app.py
calls load_dotenv() and reads os.getenv directly, and the PyInstaller spec
builds from app.py alone.

Their presence implied a coupling that does not exist, and the stale
BACKEND_URL=8000 in .env read as a misconfiguration rather than an inert
value.

Also drops the matching backend settings from .env.example, the now
unnecessary 'requests' hidden import from the spec, and the two files from
the SETUP.md tree. requests and urllib3 remain in requirements.txt - no
bridge module uses them now, but pruning dependencies is a build concern
left for a separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:49:14 +03:00
gitea c98c0eef4a Correct documented dev ports and ignore local tooling output
CLAUDE.md described the backend on 8000 and the frontend on 3000, but
frontend/.env has overridden these to 8002 and 5172 (matching .env.example
and the CORS defaults in settings.py). Following the documented ports leads
to checking servers that are not there.

Also records the CORS_ALLOWED_ORIGINS override and the real default origin
list, and notes that the frontend port comes from frontend/.env. The serial
bridge's BACKEND_URL is left at 8000 because that is what serial_bridge/.env
actually contains - it points at a port Django no longer listens on, which
is a config issue to fix separately, not a documentation error.

Adds .claude/screenshots/ and .playwright-mcp/ to .gitignore so local
verification output stays out of the repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:43:22 +03:00
gitea d10b1d263f Add selector nomenclature for conditional field visibility
A selector is a nomenclature whose value decides which other nomenclatures
appear beneath it: picking "Ship" reveals the ship fields, "Warehouse"
reveals the warehouse ones. Previously every vehicle nomenclature rendered
at once, so the operator had to know which were relevant and a vehicle
could silently carry both a ship and a warehouse.

Visibility is one rule - a nomenclature is hidden if some selector option
reveals it and that selector's value is not the revealing option - kept in
nomenclatures/visibility.py and mirrored in services/nomenclatureVisibility.js.
A nomenclature no selector references always shows, so installations without
selectors are unchanged.

The serializer, not the UI, enforces the invariant: a value for a hidden
nomenclature is rejected, as is a selector value that matches no option.
The frontend clearing values on switch and pruning hidden keys before save
is convenience on top of that.

Selector values join lookups in the quick-fill combination projection, so
applying a saved combination sets the source and its revealed fields in one
click rather than collapsing "Ship + cargo" and "Warehouse + cargo" into
one indistinguishable row.

Configuration rejects nesting (a selector revealing another selector) and
double-reveal (one nomenclature revealed by two selectors, leaving its
visibility ambiguous). The admin UI filters both out of the reveal list as
well, so an invalid configuration cannot be built in the first place.

Note: introducing a selector over existing data hides values already
entered - vehicles predating the selector have no selector key, so anything
it reveals becomes invisible until a source is chosen. The values remain
stored. Worth a data check before configuring the first selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:43:13 +03:00
gitea fe9fb71860 Add implementation plans and remove stray Word temp file
Plans record the decisions behind each feature and, for the protocol and
licensing work, what remains unvalidated against real hardware.

~WRL0003.tmp was a Word autosave artifact committed by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:58:38 +03:00
gitea 0a1b7c31e2 Wire new features into the main screen and navigation
Main.jsx now parses readings through the selected protocol rather than
an inline regex, shows one Measure button instead of separate tare and
gross, and colours the live weight green when the scale reports stable
and red while it moves. A reading with no stability marker counts as
unstable, so an unrecognised format fails safe.

While only one weighing exists it appears as "First weighing" rather
than filling in the Tare card, because a lighter second reading can
still move it to gross and a value visibly jumping between cards would
read as a bug.

Header gains admin links to the scale monitor and licence screens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:58:26 +03:00
gitea 6fe9f1820c Add licence UI and vendor signing tool
Admin screen shows the machine fingerprint to send for a licence key,
installs a pasted key, and lists connected stations. A persistent banner
warns all users during the grace period.

The browser sends the station heartbeat rather than the bridge, because
the bridge deliberately has no dependency on Django; it already talks to
both. Two tabs on one station share a slot, and a station reconnecting
at the limit reclaims its own slot so a brief drop cannot lock it out
behind its own expiring registration.

license_tool.py is the only thing that can sign a licence - the
application can only verify. The private key it generates is gitignored
and must never ship: if it leaks every licence becomes forgeable, and it
cannot be rotated without reissuing to every customer, since the public
key is compiled into shipped binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:58:10 +03:00
gitea 5a8527e96e Add multi-protocol scale support and admin COM monitor
The bridge becomes pure transport: it opens the port, writes a poll
string it is given at runtime, and streams raw lines. Protocol
definitions live in Django and parsing happens in the browser, so a new
scale type never means a new binary on the station PCs. The poll loop
stays in the bridge because a browser cannot be the heartbeat - hidden
tabs get throttled and a closed tab would stop the scale.

Every payload now carries a hex field. Toledo Continuous sends STX and
bit-packed status, which UTF-8 decoding silently corrupts. Fixed-column
protocols need the opposite care: a space can mean balanced, so the
trailing whitespace must survive.

Parsers for the simulator, fixed-column, Toledo Continuous and SICS
return an explicit error rather than a guess on a frame that does not
match, since a silently wrong weight on a weighbridge is worse than no
weight. Toledo and SICS are written from documented structure and have
not been validated against real hardware.

The admin monitor shows the raw stream as text and hex, sends one-off
probe commands, and builds fixed-column protocols against captured
lines. Spaces render as middots because in some protocols a space is
the stability flag.

The test writer now simulates a weighing cycle - idle, ramp, balanced,
drop - alternating empty and loaded trucks, quantised to the 20kg and
50kg divisions real truck scales use.

Bridge logging rotates and per-reading lines drop to DEBUG; the old
config had grown two log files totalling 368MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:58:00 +03:00
gitea 67c9531600 Add quick-fill table for vehicle extra data
Shows previously-used combinations of lookup nomenclature values above
the Additional Data fields in both the new-vehicle and edit forms.
Clicking a row merges the combination into the current values, so
free-text fields the operator already typed are preserved.

NomenclatureContext now fans extra_combination events out to
subscribers; without that they hit the unknown-object warning and were
dropped, so deletes would not have reached other open tabs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:57:36 +03:00
gitea 4b6138f049 Add extra-data combinations, single measure flow, protocols and licensing
Four related backend features. They are committed together because they
interleave in api/views.py and api/serializers.py - one hunk holds the
combinations ViewSet, the licensing helpers and LicenseView - and
splitting by hunk would produce commits that do not import.

ExtraCombination records previously-used combinations of lookup
nomenclature values so an operator can refill the Additional Data fields
in one click. Deduplicated by a canonical order-independent hash, since
JSONField equality compares serialized text on SQLite and so depends on
key order.

Weighing replaces set_tare/set_gross with a single measure action that
assigns by weight - lighter becomes tare, heavier gross - because trucks
arrive both loaded and empty. A second, lighter reading moves the first
to gross carrying its own timestamp, user and manual fields, so
provenance follows the value rather than the slot. Manual entries record
who, when and why, gated on the previously unenforced can_measure and
can_manually_measure flags.

ScaleProtocol stores how to read each indicator, so a new scale type is
a configuration change rather than a new binary on every station PC.

Licensing is offline and node-locked: the server verifies an Ed25519
signature against a fingerprint of four hardware components, tolerating
one change, and counts concurrently connected stations. Sites are
air-gapped, so there is no activation server. A 14-day grace period
keeps an unlicensed or newly-invalid install working, because a licence
check must never stop a weighbridge without warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:57:29 +03:00
gitea 183908cbab Add drag-to-reorder for nomenclatures
Nomenclatures can be reordered by dragging in the left-hand list, which
PATCHes sort_order on the rows that moved. PATCH rather than PUT, since a
full update recreates the nested fields collection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:56:21 +03:00
gitea 19945dae19 Move dev servers to ports 8002 and 5172
Backend now runs on 8002 and the frontend dev server on 5172, with the
proxy, CORS origins and the run_*.bat launchers updated to match. The
launchers also activate the venv so they work from a clean shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:56:14 +03:00
177 changed files with 23967 additions and 16917 deletions
@@ -8,8 +8,22 @@
- Inspector CSS: `ObjectInspector.css` - Inspector CSS: `ObjectInspector.css`
- Data resolver utils: `utils/dataResolver.js` — `resolveDBTextValue`, `getAvailableObjects`, `getAvailableFields` - Data resolver utils: `utils/dataResolver.js` — `resolveDBTextValue`, `getAvailableObjects`, `getAvailableFields`
- Expression evaluator: `utils/expressionEvaluator.js` — recursive-descent, no eval - Expression evaluator: `utils/expressionEvaluator.js` — recursive-descent, no eval
- A4 designer (sibling tree, mm-based): `frontend/src/components/ReportEditor/a4/`
## Element Type Pattern ## Two editors, deliberately duplicated
The matrix editor (`EditorCanvas.jsx`, `models/Element.jsx`, `ResizeHandles.jsx`,
`ObjectInspector.jsx`, `ReportEditor.jsx`) is treated as frozen. The A4 designer under
`a4/` copies similar logic rather than extracting shared abstractions — per the plan at
`.claude/plans/a4-report-designer.md`. Do not "DRY up" the two; the duplication is the point.
Key unit difference: matrix bounds are **inclusive** (`x + width - 1`); A4 bounds in
`a4/geometry.js` are **exclusive** (a 40mm box at x=10 has right edge 50).
Safely reusable by both: `hooks/useElementDrag.jsx` (emits raw pixel deltas, snapping left
to the consumer), `hooks/useElementSelection.jsx`, `DataContext`, `BandContext`.
## Element Type Pattern (matrix editor)
Adding a new element type requires touching 8 files: Adding a new element type requires touching 8 files:
1. `models/Element.jsx` — new class + register in `elementClasses` map in `fromJSON` 1. `models/Element.jsx` — new class + register in `elementClasses` map in `fromJSON`
@@ -50,3 +64,20 @@ resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, { currentBand
`[objectKey.fieldPath]` for top-level fields, `[fieldPath]` for band-relative fields. `[objectKey.fieldPath]` for top-level fields, `[fieldPath]` for band-relative fields.
Strip brackets, split on first `.` to get objectKey and fieldPath. Strip brackets, split on first `.` to get objectKey and fieldPath.
## Build tooling
**Vite + Vitest** (no CRA/Jest). `npm run dev` / `npm start` → port 5172;
`npm test` → `vitest run`; `npm run build` → `frontend/build/`.
Config: `frontend/vite.config.mjs`. Env vars are `import.meta.env.VITE_*`.
**Any new production file containing JSX must be named `.jsx`** — a `.js` file
with JSX passes tests and the dev server but fails `vite build`.
See [vite-vitest-migration](vite-vitest-migration.md).
## Detailed notes
- [Vite + Vitest build tooling](vite-vitest-migration.md) — the JSX-in-.js build trap, axios mocks, jsdom `border: none`
- [A4 entry points](a4-entry-points.md) — report `kind` decides the editor/print route; the two contexts A4ReportEditor now needs in tests
- [A4 smart guides](a4-smart-guides.md) — px-not-mm snap threshold; why useElementDrag must stay untouched; the drag-test gotcha snapping introduces
- [Testing React in this repo](testing-react.md) — no testing-library installed; jsdom/React event gotchas that cost real debugging time
- [Decimal number inputs](decimal-inputs.md) — why `type="number"` breaks mm/float entry
@@ -0,0 +1,44 @@
---
name: a4-designer-conventions
description: Non-obvious invariants of the A4 report designer — line element shape, mm-vs-px flooring, and why matrix inspector panels must not be imported
metadata:
type: project
---
Conventions in `frontend/src/components/ReportEditor/a4/` that are easy to get
wrong because they contradict the matrix editor sitting next to them.
**Lines carry only one dimension.** An `a4-hline` has `width` + `thickness`
and NO `height`; an `a4-vline` has `height` + `thickness` and NO `width`.
Anything computing a box from an element must substitute `thickness` for the
missing axis — `getA4Bounds` in `geometry.js` does this, and it is the
function to reach for rather than reading `el.width`/`el.height` directly.
Reading the raw props for a line compares against `undefined` and passes
vacuously in tests while being wrong in the product.
**Screen floors hairlines, print must never.** Screen renderers clamp a line's
painted thickness and a rect's border to `Math.max(1, …)` px, because 0.3mm at
50% zoom is 0.57px and browsers can round it away to nothing. `PrintDocument`
deliberately does no such thing — the printed thickness is what a steel rule
measures. If a test asserts a floored value on the print path, the test is
wrong, not the code.
**Do not import the matrix inspector panels.** `ExpressionEditorPanel.jsx`
looks tempting for the A4 expression section but is coupled two ways: it finds
a parent band with `el.type === 'band'` (A4 bands are `a4-band`) and its
alignment select writes `element.alignment`, whereas every A4 element and
renderer reads `element.align`. Reusing it silently creates a dead second
alignment property. The duplication between the two inspectors is accepted
deliberately — see the plan's "matrix editor is not modified" section.
**`fillColor: null` means no fill,** distinct from white, which would hide
whatever a rect is framing. A colour input cannot express null, so a checkbox
owns that state. It round-trips through Django's JSONField as SQL null.
**Float inputs use `MmInput`,** never `<input type="number">`: a number input
returns `""` for the intermediate value `"12."`, so the decimal point appears
not to type. `MmInput` (in `A4Inspector.jsx`) holds a draft string while
focused. Font size in pt is the one exception — its step is 0.5 and users
never type a long decimal tail there.
Related: [[vite-vitest-migration]]
@@ -0,0 +1,46 @@
---
name: a4-entry-points
description: How the two report editors are reached and dispatched between — kind decides the route, and why the A4 editor now needs Router + NomenclatureData context in tests
metadata:
type: project
---
Phase 7 wired the A4 designer to real operator entry points. Three things here
are easy to get wrong later.
**Report `kind` decides the route, never a default.** `Main.jsx` fetches
`/api/reports/?name=notice_report` and branches: `kind === 'a4'` → dynamic
`import()` of `a4/print/printReport` and `printA4Report(report, data)`;
otherwise the existing `ReportPreviewModal`. "Design Notice" routes to
`/report-editor-a4` or `/report-editor` by the same field.
**Why it matters:** a matrix report opened in the mm editor reads its character
x/y as millimetres and looks like a corrupt layout rather than a routing bug —
a slow, confusing failure. The A4 editor's auto-load therefore filters on
`?kind=a4&name=...`, not name alone; verified against the real DB that
`?kind=a4&name=<a matrix report>` returns 0 rows.
**How to apply:** any new entry point that opens a saved report must resolve
`kind` before choosing an editor or a render path. Never assume a name implies
a kind.
**`A4ReportEditor` now requires two contexts.** It calls `useLocation`/
`useNavigate` (router-state auto-load) and `useNomenclatureData` (sample-data
fallback). Both throw outside their providers, so any jsdom test mounting the
whole editor needs `<MemoryRouter>` plus
`vi.mock('../../../../contexts/NomenclatureDataContext', ...)`. Mocking the
nomenclature hook rather than standing up the provider keeps the SSE client and
auth context out of tests that are really about drag geometry.
**Why:** adding these deps silently broke four existing `a4Guides` tests with
"useLocation() may be used only in the context of a <Router>" — the component
was fine, the harness was stale.
**`window.print()` is not gated by transient user activation** the way
`window.open` is, so an `await` before it is safe. The A4 print chunk is still
prefetched on hover/focus of the Print Notice button, purely to shorten the gap
on a slow station. This was reasoned, not verified in a real browser — if Print
Notice is ever reported as doing nothing on click, re-test that assumption
first.
Related: [[a4-print-path]], [[a4-designer-conventions]], [[testing-react]]
@@ -0,0 +1,35 @@
---
name: a4-print-path
description: Two non-obvious constraints in the A4 report print path — the print-root must be a direct body child, and flushSync (not rAF) must precede window.print()
metadata:
type: project
---
Two constraints in `frontend/src/components/ReportEditor/a4/print/` that are easy to
silently break and whose failure mode is a blank or wrong printed page.
**1. `.print-root` must sit on the element appended directly to `<body>`.**
`print.css` hides the app with `body > *:not(.print-root)` — a CHILD combinator. The app
mounts into `<div id="root">`, itself a direct body child, so the print container is its
sibling. If the class lived only on `PrintDocument`'s inner div, the appended container
would match the hiding rule and print a blank page.
**Why:** the selector is the entire mechanism for hiding the editor during print; there is
no other switch.
**How to apply:** when changing how the print container mounts, keep the class on the
container itself. `printReport.js` sets it; `PrintDocument` repeats it for the standalone
case.
**2. `flushSync` — never `requestAnimationFrame` — before `window.print()`.**
`window.print()` reads the DOM synchronously while React 18 commits asynchronously, so an
unflushed render prints an empty container. rAF was the first fix tried and is wrong: it
does not fire in a background or hidden tab, so a print click would hang instead of
printing.
**Why:** discovered when tests showed `window.print` never being reached behind a
double-rAF await; the background-tab case is the production version of the same bug.
**How to apply:** any new print entry point wraps its `root.render` in `flushSync` from
`react-dom`.
Related: `.a4-print-page` is deliberately NOT `.a4-page` — `A4Canvas.css` already owns
`.a4-page` for the on-screen editor canvas, and both stylesheets load together.
@@ -0,0 +1,35 @@
# A4 smart guides (`a4/guides.js`)
Pure functions, no React/DOM. `buildGuideCandidates(elements, excludeIds, pageSetup)`
→ `{V, H}`; `resolveSnap(draftRect, candidates, zoom)` → `{x, y, guides}`;
plus `unionRect(boxes)` and `snapToleranceMm(zoom)`.
## The threshold is in SCREEN PIXELS, not mm
`SNAP_PX = 6`, converted per-call via `SNAP_PX / (PX_PER_MM * zoom)`. This is
load-bearing, not a detail: a fixed mm threshold feels sticky zoomed in and
useless zoomed out. Do not "simplify" it to a constant mm value. The property
worth asserting is `tolMm * PX_PER_MM * zoom === SNAP_PX` at every zoom.
## Integration seams in `A4Canvas.jsx`
- Candidates are built **once** in `handleElementDragStart` into a `useRef`.
Rebuilding per mousemove is the one thing that makes dragging laggy.
- Multi-select snaps the **union rect**, then applies one delta to every member.
Snapping members independently tears the group apart.
- `hooks/useElementDrag.jsx` is shared with the matrix editor and forwards only
pixel deltas — **never widen it**. Alt-to-suppress is tracked with a
window-level `keydown`/`keyup` listener in A4Canvas, plus a `blur` reset (an
alt-tab otherwise leaves snapping permanently off, because the keyup lands in
another window).
- Bands emit no candidates (their edges *are* the margins, already candidates),
but band children do.
## Gotcha: snapping breaks drag tests that assume raw deltas
Any pre-existing test that drags an element and asserts the exact px→mm result
can fail once a candidate happens to sit within ~1.6mm of where it lands. This
bit `a4Elements.test.js`'s rect case: it drifted 0.05mm from the 20mm left
margin and legitimately snapped. The fix is to hold Alt around the drag
(`withAltHeld` helper in that file), not to weaken the assertion — the test is
about unit conversion, so switching the magnet off states its intent.
@@ -0,0 +1,40 @@
# Decimal / float number inputs
Rule: for a field where the user types a **decimal** value (millimetres, rates, any float),
use `type="text"` + `inputMode="decimal"` with local draft-string state — **not**
`type="number"`.
## Why
Two independent failure modes combine:
1. **`type="number"` sanitises partial input.** While the user is typing `12.7`, the
intermediate value is `"12."`, which is not a valid number. The browser (and jsdom)
report `input.value === ""`. The decimal point appears not to type at all.
2. **A naive `parseFloat` + `isNaN` guard drops the same intermediate.** `parseFloat("12.")`
is `12`, so a commit-on-every-change handler rewrites the field to `"12"` and the caret
jumps — same visible symptom, different cause.
## The working pattern
Keep the raw string in local state while focused; commit only when it parses cleanly;
re-sync from the prop only when not focused (so drag/resize updates still flow in).
```jsx
const [draft, setDraft] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) setDraft(String(value ?? ''));
}, [value, isFocused]);
// onChange: setDraft(next) always; commit only if next is a complete number
// onBlur: if the draft is empty/unparseable, restore String(value); else commit rounded
```
Reference implementation: `MmInput` in
`frontend/src/components/ReportEditor/a4/A4Inspector.jsx`.
Note the matrix editor's `ObjectInspector.jsx` uses `parseInt` and integer cells, so it
does not need this — only float-valued fields do.
@@ -0,0 +1,65 @@
# Testing React components in ScalesApp
## What's available
- **Vitest + jsdom** (`npm test` → `vitest run`). Run a single suite with:
`npx vitest run <path-to-test-file>`. Use `vi.*` (not `jest.*`); `globals: true`
is set, so `describe`/`test`/`expect`/`vi` need no import. See
[[vite-vitest-migration]].
- **`@testing-library/react` is NOT installed.** Don't assume it; mount with
`createRoot` from `react-dom/client` and `act` from `react` instead.
- There is no `src/setupTests.js`. Set `global.IS_REACT_ACT_ENVIRONMENT = true` in the
test file itself, or React logs "testing environment is not configured to support act(...)".
- Import `act` from `react`, not `react-dom/test-utils` (the latter is deprecated in React 18
and prints a warning on every call).
## Three gotchas that produce fake failures
These each look like a component bug and are not. Verify with a tiny probe test before
"fixing" component code.
1. **React's `onBlur`/`onFocus` map to native `focusout`/`focusin`.**
Dispatching `new FocusEvent('blur')` does nothing — the handler never runs, so the
component looks broken. Dispatch `focusout`/`focusin` instead.
2. **`MouseEvent` coerces `clientX`/`clientY` to integers.**
Passing a fractional pixel coordinate silently truncates. In a mm-based editor that
shows up as a ~0.3mm discrepancy that looks like a rounding bug in the conversion code.
Use whole pixels in tests and derive expected values from those same integers.
3. **`<input type="number">` in jsdom returns `""` for a partial value like `"12."`.**
Real browsers sanitise similarly. See [[decimal-inputs]].
## jsdom has no layout
`getBoundingClientRect()` returns all zeros. For canvas/positioning components, stub it
per test to pin a known origin:
```js
Element.prototype.getBoundingClientRect = function () {
if (this.classList.contains('a4-page')) {
return { left: 100, top: 50, /* ...*/ toJSON() {} };
}
return { left: 0, top: 0, /* ... */ toJSON() {} };
};
```
## Driving controlled inputs
React overrides the `value` setter, so `input.value = x` alone won't trigger `onChange`.
Use the native setter, then dispatch `input`:
```js
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(input, '12.');
input.dispatchEvent(new Event('input', { bubbles: true }));
```
## Backend round-trips without a live server
Outbound HTTP from the agent sandbox may be blocked even when the dev server is running.
To exercise real DRF serializers/filters/DB, use Django's test client directly against the
dev database — pass `APIClient(SERVER_NAME='localhost')`, since the default `testserver`
host is rejected by `ALLOWED_HOSTS`.
@@ -0,0 +1,52 @@
---
name: vite-vitest-migration
description: Frontend build is Vite + Vitest (not CRA/Jest) — the JSX-in-.js trap, why axios no longer needs stubbing, and the jsdom border assertion gotcha
metadata:
type: project
---
The frontend migrated from Create React App / Jest to **Vite + Vitest**. Config
lives in `frontend/vite.config.mjs` (`.mjs`, not `.js` — Vite's native config
loader warns about ESM in a `.js` config).
Replaces the old `jest-axios-esm` note, which is now obsolete.
**Vitest transforms axios fine, so the old blanket `jest.mock('axios')` hack is
gone.** Three of the six a4 suites dropped it entirely. `a4EntryPoints` still
mocks axios, but for a *behavioural* reason: it drives the report auto-load via
`api.get.mockResolvedValue(...)`, so the instance `services/api.js` builds at
import time must be a mock. Distinguish the two reasons before deleting a mock.
**The JSX-in-`.js` trap — this one fails at BUILD time only.** CRA's Babel
parsed JSX inside `.js` files; Vite does not by default. Setting
`oxc: { include: /\.(m?ts|[jt]sx|js)$/, lang: 'jsx' }` fixes the dev server and
Vitest, but **not** `vite build`: the bundled path (Rolldown,
`builtin:vite-transform`) derives the language from the file extension and
ignores `lang`. So a `.js` file containing JSX will pass tests and the dev
server, then fail the production build.
**How to apply:** any new production file containing JSX must be named `.jsx`.
The `oxc` override exists only so the existing a4 `__tests__/*.test.js` files
keep working. Importers use extensionless specifiers, so renaming a file
`.js` → `.jsx` needs no import changes (this is how `print/printReport.jsx` was
renamed without touching the dynamic `import()` in `Main.jsx`).
**Env vars are `import.meta.env.VITE_*`**, not `process.env.REACT_APP_*`;
`process.env` does not exist in the browser. The `|| 'http://localhost:…'`
fallbacks are the offline-deployment safety net — keep them. The dev-server
port (5172, in `vite.config.mjs`) must match Django's `CORS_ALLOWED_ORIGINS`.
Renaming these vars means touching `Dockerfile`, `docker-compose.yml`,
`.env.docker` and the gitignored root `.env` too, or the deployed bundle
silently falls back to localhost.
**Newer jsdom round-trips `border: none`.** It now reports
`borderStyle === 'none'` with `borderWidth === 'medium'` (the CSS initial
keyword). Assert on **`borderStyle`**, not `borderWidth === ''` — the latter was
written around an old-jsdom quirk and passed vacuously. Two `a4Elements` tests
had to be corrected for this during the migration; the components were correct.
**`global.IS_REACT_ACT_ENVIRONMENT = true` is no longer required** (Vitest's
jsdom environment sets it), but it is harmless and still present in all six
suites.
Related: [[testing-react]], [[a4-print-path]], [[a4-entry-points]]
Binary file not shown.
+540
View File
@@ -0,0 +1,540 @@
# A4 Free-Positioning Report Designer + Print / Save-as-PDF
## Context
Some customers print on A4 laser printers rather than dot-matrix printers. The existing
report designer is built entirely around an 80×66 **character grid** — elements snap to
cells, frames and lines are drawn with Unicode box-drawing characters, coordinates are
integer columns/rows. None of that maps onto an A4 page where a field must sit at an
arbitrary millimetre position.
A second discovery shapes this as much as the first: **the app cannot print at all today.**
"Print Notice" in `Main.jsx` opens `ReportPreviewModal`, which renders the report as HTML in
an on-screen modal. There is no `window.print()`, no print CSS, no PDF library anywhere.
Confirmed by grep: zero matches for `@page`, `@media print`, `window.print` in
`frontend/src`. So the output path is new code regardless.
**Outcome:** a second designer where elements are positioned freely in millimetres, with
alignment guides, that can be printed on A4 — and saved as a PDF when the operator wants a
file.
### Decisions taken
| Decision | Choice | Why |
|---|---|---|
| Positioning | Millimetres (floats), free | No grid; the mm typed is the mm printed |
| Alignment aids | Smart guides on drag + page centre/margin snapping | Not ruler guides, not a background grid |
| Bands | Kept, incl. multi-page overflow | Needed for list reports, not just one-page tickets |
| Fonts | **Any font installed on the station** | See below |
| Print | `window.print()` + `@page` CSS | Chrome renders with the machine's real fonts |
| PDF | **Operator picks "Save as PDF"** in the same print dialog | A file, real fonts, zero dependencies |
| Puppeteer | **Deferred**, socket left open | Only needed if code must make a PDF with no human |
### Why browser printing, not a PDF library
The requirement is *"the operator gets a PDF when they want one."* Chrome's print dialog
already has **Save as PDF** built in (as does Windows' "Microsoft Print to PDF"). Same
`window.print()` call — the operator picks a different destination and gets a file.
This matters because it settles the font question the right way round. A client-side PDF
library (pdfmake, pdf-lib) can only use fonts whose **bytes it embeds** — JavaScript cannot
read `C:\Windows\Fonts`. That would force Calibri and Segoe UI to substitute with metric
clones. But **Chrome prints with the fonts installed on the station**. The customer paid for
those fonts; they should be usable.
So: no PDF library, no embedded fonts, no substitution, no per-font upload step. Design in
Calibri → prints in Calibri → saves to PDF in Calibri.
**Millimetre accuracy comes from CSS**, which has real physical units:
```css
@page { size: A4 portrait; margin: 0; }
.a4-page { width: 210mm; height: 297mm; position: relative; }
.a4-el { position: absolute; } /* left: 20mm; top: 50mm */
```
Chrome maps `mm` to physical millimetres at 100% scale.
### Why Puppeteer is deferred (not rejected)
Puppeteer is real Chrome driven by code — best fidelity, and it uses installed fonts too.
It is the right tool **the day code must generate a PDF with no human in the loop**
(auto-archiving to the vehicle record, emailed tickets, nightly batches). Nothing on the
table needs that now.
Deferred because: ~150–300 MB of Chromium plus a Node runtime inside an **offline
`Setup.exe`**, and a third service to install and supervise at every site — the most
expensive thing in the box, for a one-page ticket. And server-side Puppeteer uses the
**server's** fonts; at the reference site (1 server, 10 stations) that is the wrong machine,
so fonts would have to be installed server-side to match — a problem browser printing simply
does not have.
**The socket:** `layout()` is a pure function and `PrintDocument` emits plain HTML+CSS with
no React-only tricks. Adding Puppeteer later means POSTing that same HTML to a small Node
service (~60 lines) and returning bytes. An afternoon, not a redesign.
### Known rough edges (one-time per station, belong in the install checklist)
- Chrome adds its own header/footer (URL, date) unless **"Headers and footers"** is unticked
in the print dialog. Sticky per machine once set.
- Scale must stay at **100% / "Actual size"**, not "Fit to page", or millimetres stop being
millimetres.
Neither is a code problem. If they become a field support burden, *that* is the trigger to
revisit Puppeteer — and the argument will be about silent printing, not fonts.
---
## Architecture
One layout engine, renderers downstream of it are dumb.
```
Report JSON (elements in mm) + vehicle data
│
▼
layout() pure function — no React, no DOM
│ resolves bindings, iterates bands,
│ paginates → pages[] of {element, x, y} in mm
▼
┌────────┴────────┐
▼ ▼
PrintDocument (future) Puppeteer
mm → CSS mm same HTML → page.pdf()
```
`layout()` returning `pages[]` is the load-bearing decision: **the same output drives the
on-screen preview and the printed page.** That is what makes "preview page count equals
printed page count" a real test rather than a coincidence, and it is what keeps a future PDF
renderer cheap.
### The matrix editor is not modified
The A4 designer is a **sibling tree** under `frontend/src/components/ReportEditor/a4/`.
`EditorCanvas.jsx`, `models/Element.jsx`, `ResizeHandles.jsx`, `ObjectInspector.jsx` are
**not refactored** — they are the code path the one working report feature depends on, and
CLAUDE.md §3 asks for surgical changes over DRY.
Three things genuinely cannot be shared, which settles it:
- [`models/Element.jsx:141-147`](frontend/src/components/ReportEditor/models/Element.jsx#L141-L147) — `getBounds()` is **inclusive** (`x + width - 1`). Right for cells, wrong for mm. Everything funnels through it via `elementUtils.js`, so changing it changes matrix behaviour.
- [`ResizeHandles.jsx:120-121`](frontend/src/components/ReportEditor/ResizeHandles.jsx#L120-L121) — rounds pixel deltas to integer cells; line 55 hardcodes `80 * charWidth` for bands.
- [`ObjectInspector.jsx:24-29`](frontend/src/components/ReportEditor/ObjectInspector.jsx#L24-L29) — `parseInt` only; A4 needs `parseFloat`.
Cost: ~200 duplicated lines of inspector data-binding/band sections. Benefit: zero
regression risk on the working editor. Only **5 existing files** are touched, all additively.
---
## 1. Units and geometry
**Storage is mm floats, always. Never px, never zoom-affected values.**
```js
// a4/units.js
export const PX_PER_MM = 96 / 25.4; // 3.7795 — CSS px at 100% zoom
export const mmToPx = (mm, zoom = 1) => mm * PX_PER_MM * zoom;
export const pxToMm = (px, zoom = 1) => px / (PX_PER_MM * zoom);
export const roundMm = (mm) => Math.round(mm * 10) / 10; // 0.1 mm precision
export const PAGE = { A4: { portrait: { w: 210, h: 297 }, landscape: { w: 297, h: 210 } } };
```
- A4 portrait at zoom 1 → **793.7 × 1122.5 px**; fits a 1080p station screen.
- Zoom 50/75/100/150 %, one `zoom` prop threaded down. Every render path already goes through
`mmToPx(v, zoom)`, so zoom stays a one-line concern.
- **Font size in pt** (what users expect). On screen `pt * (96/72) * zoom` px; in print CSS,
`pt` directly — CSS has real `pt` units, so no conversion at all on the print path.
- `roundMm` on every commit prevents `12.700000000000001` in the JSON.
**Bounds are exclusive**, in a new `a4/geometry.js`. A 40 mm box at x=10 occupies [10, 50)
and its right edge — what guides align to — is **50**, not 49. `getA4Bounds(el)` handles the
`hline`/`vline` cases.
**Float input:** `A4Inspector` uses `parseFloat` + `step="0.1"` in a small `<MmInput>` with
local draft-string state — otherwise typing `"12."` yields `NaN`, the guard drops it, and the
decimal point appears not to type.
---
## 2. Backend
Reuse `Report`; add a discriminator. This copies the in-repo pattern from `ScaleProtocol`
([`models.py:66-100`](backend/api/models.py#L66-L100)), which already pairs a `kind`
CharField with a `config` JSONField.
**Do not repurpose `page_width`/`page_height`** — they are `IntegerField` meaning character
columns/rows. Overloading them with mm makes `80` ambiguous and risks a matrix report
rendering as an 80 mm page.
### [`backend/api/models.py:45-63`](backend/api/models.py#L45-L63)
```python
class Report(models.Model):
MATRIX = 'matrix'
A4 = 'a4'
KIND_CHOICES = [(MATRIX, 'Character matrix'), (A4, 'A4 free positioning')]
name = models.CharField(max_length=255)
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX)
page_width = models.IntegerField(default=80) # chars — matrix only
page_height = models.IntegerField(default=66) # chars — matrix only
# mm page setup for kind='a4': page, orientation, margins{top,right,bottom,left},
# defaultFont, defaultFontSize
page_setup = models.JSONField(default=dict, blank=True)
... rest unchanged ...
```
`default=MATRIX` is the point: **every existing row keeps working untouched.**
- **New** `backend/api/migrations/0009_report_kind_page_setup.py` — two additive `AddField`s
with defaults. No data migration, no backfill. (Latest is `0008_license_stationslot.py`.)
- [`serializers.py:487-504`](backend/api/serializers.py#L487-L504) — add `'kind'` and
`'page_setup'` to `fields`; add `validate_page_setup` (must be a dict).
- [`views.py:621-644`](backend/api/views.py#L621-L644) — `filterset_fields = ['name', 'kind']`,
enabling `?kind=a4` for the load dialog and `?name=notice_report` for Main.jsx.
---
## 3. Files
### New
```
frontend/src/components/ReportEditor/a4/
A4ReportEditor.jsx Orchestrator: state, save/load (sends kind + page_setup),
load dialog (?kind=a4), shortcuts, zoom, Print button.
Wraps the reused DataProvider + BandProvider.
A4Canvas.jsx Core: page canvas, click-to-create, drag via useElementDrag,
smart-guide integration, rubber-band select, margin/centre
overlay, preview driven by layout().
A4Toolbar.jsx Tools, zoom, orientation, Preview, Save, Load, Print
A4Inspector.jsx Float-mm geometry (MmInput) + font controls
+ verbatim copies of the dbtext/expr/band sections
A4PageSetup.jsx Orientation, 4× margin mm, default font + size
units.js Conversions, roundMm, PAGE dims
geometry.js getA4Bounds (exclusive), a4IntersectsRect
fonts.js Font list + CSS fallback stacks
guides.js Smart-guide engine — pure, no React (§4)
layout.js Band instancing + pagination + data resolution (§5)
A4Canvas.css Page/shadow/guide/overlay styles (screen only)
a4Elements.css Per-type styles — CSS borders, not box characters
elements/ A4TextField, A4DBTextField, A4ExprField, A4Rect,
A4HLine, A4VLine, A4Band, A4ResizeHandles
print/
PrintDocument.jsx pages[] → absolutely-positioned mm HTML. No React-only
tricks — this is the exact markup a future Puppeteer eats.
print.css @page rules + .print-only visibility (§6)
printReport.js Non-React entry for Main.jsx: report + data → layout()
→ mount PrintDocument → window.print() → unmount
backend/api/migrations/0009_report_kind_page_setup.py
```
### Modified — 5 files, all additive
| File | Change |
|---|---|
| [`backend/api/models.py`](backend/api/models.py) | `kind` + `page_setup` on `Report` |
| [`backend/api/serializers.py`](backend/api/serializers.py) | both fields + `validate_page_setup` |
| [`backend/api/views.py`](backend/api/views.py) | `filterset_fields = ['name', 'kind']` |
| [`frontend/src/App.jsx:69`](frontend/src/App.jsx#L69) | add `/report-editor-a4` route, same `can_edit_documents` gate |
| [`frontend/src/components/Main.jsx:585-601`](frontend/src/components/Main.jsx#L585-L601) | kind-aware Print Notice dispatch (§7) |
No new npm dependencies.
### Reused untouched
`DataContext.jsx`, `BandContext.jsx`,
[`hooks/useElementDrag.jsx`](frontend/src/components/ReportEditor/hooks/useElementDrag.jsx)
(emits **raw pixel deltas** at lines 35-37, leaving snapping to the consumer — exactly the
seam smart guides need), `hooks/useElementSelection.jsx`, `utils/dataResolver.js`,
`utils/expressionEvaluator.js`, `utils/bindingParser.js`, `ReportEditor.css` (dialog shell),
`services/api.js`.
**One low-risk extraction:** move the sample-data block
([`ReportEditor.jsx:71-116`](frontend/src/components/ReportEditor/ReportEditor.jsx#L71-L116))
into `utils/sampleData.js` as a pure `buildSampleVehicleData(definitions, entries)`. Move,
don't change — both editors call it so sample data can't drift. If it feels invasive
mid-build, duplicate instead.
### A4 element JSON
Types prefixed (`a4-text`, …) so a mis-routed renderer fails loudly rather than silently
mis-rendering. Positions in **mm floats**, `fontSize` in **pt**.
```js
{ id, type:'a4-text', x, y, width, height, content,
fontFamily:'Arial', fontSize:10, bold, italic, align:'left', color:'#000000' }
{ id, type:'a4-dbtext', x, y, width, height, objectKey, fieldPath, dataType, ...font }
{ id, type:'a4-expr', x, y, width, height, expression, dataType, ...font }
{ id, type:'a4-rect', x, y, width, height, borderWidth:0.3, borderColor, fillColor }
{ id, type:'a4-hline', x, y, width, thickness:0.3, color }
{ id, type:'a4-vline', x, y, height, thickness:0.3, color }
{ id, type:'a4-band', x:0, y, width, height, bandType, dataSource, caption,
children:[], parentBandId }
```
Lines use `width`/`height` rather than the matrix `length`, keeping bounds and resize
uniform. Band children store `y` **relative to the band**, same as matrix
([`ReportEditor.jsx:130-134`](frontend/src/components/ReportEditor/ReportEditor.jsx#L130-L134)),
so the binding mental model carries over.
`page_setup`: `{ page:'A4', orientation:'portrait', margins:{top:15,right:15,bottom:15,left:20}, defaultFont:'Arial', defaultFontSize:10 }`
### Fonts
Since Chrome prints with installed fonts, the dropdown offers the standard Windows set —
Arial, Times New Roman, Courier New, **Calibri, Segoe UI, Tahoma, Verdana** — all of which
ship with Windows and cover Cyrillic. Each is emitted with a fallback stack
(`"Calibri, Arial, sans-serif"`). Free-text entry for a customer's own corporate font is a
one-line addition if asked for; not built speculatively.
Same `font-family` string is used on screen and in print, so **WYSIWYG holds by
construction** — one font stack, one renderer engine.
---
## 4. Smart guides
Pure functions in `a4/guides.js` — no React, no DOM; testable, cheap per mousemove.
**Candidates**, built **once per drag** (held in a `useRef`, not recomputed per mousemove):
for every non-dragged element, 3 vertical (left / centreX / right) and 3 horizontal
(top / middleY / bottom) from its exclusive bounds. Plus page-level: `0`, `pageW/2`, `pageW`
and each margin boundary; same for the vertical axis.
**Snap resolution:** the dragged element offers 3 of its own edges per axis; for each
(myEdge × candidate) pair take `|myEdgePos − candidate.pos|`, keep the per-axis minimum,
snap if under threshold.
```js
export const SNAP_PX = 6; // threshold in SCREEN px
const tolMm = SNAP_PX / (PX_PER_MM * zoom); // → identical feel at every zoom
```
Converting the threshold **through the current zoom** is what makes the magnet feel the same
at 50 % and 150 % — what users actually expect. At 100 % it is ≈1.6 mm.
**Rendering & performance:**
- Guides are 2 absolutely-positioned 1 px divs in a `pointer-events:none` overlay, driven by
local `A4Canvas` state — a drag re-renders the overlay, not the element tree.
- 100 elements ≈ 600 candidates; inner loop is 3×600 float comparisons per axis per move.
Sub-millisecond; no spatial index needed at report scale.
- **Hold `Alt` to suppress snapping** — standard convention, essential escape hatch.
- Multi-select drag: snap the union bounding rect, apply the delta to all selected.
**Arrow-key nudge** in the A4 keyboard handler: arrow = 1 mm, Shift+arrow = 0.1 mm. Highest
-value precision affordance on a free-positioning designer, ~10 lines.
---
## 5. `layout()` — bands, data, pagination
Ports `calculateBandInstances` from
[`utils/bandRenderer.js`](frontend/src/components/ReportEditor/utils/bandRenderer.js) with
three changes:
1. All arithmetic in mm floats.
2. **Drop the inclusive `-1`/`+1`.** The matrix version uses
`band.y - previousBandDesignEnd - 1` ([line 89](frontend/src/components/ReportEditor/utils/bandRenderer.js#L89))
and `previousBandPreviewEnd + 1 + designGap` ([line 91](frontend/src/components/ReportEditor/utils/bandRenderer.js#L91)),
tracking ends as `band.y + band.height - 1` and `currentY - 1`
([lines 170-171](frontend/src/components/ReportEditor/utils/bandRenderer.js#L170-L171)).
In mm: `designGap = band.y - previousBandDesignEnd`, with
`previousBandDesignEnd = band.y + band.height`.
3. `calculateTotalContentHeight`'s hardcoded `minHeight = 66`
([line 185](frontend/src/components/ReportEditor/utils/bandRenderer.js#L185)) becomes the
page content height in mm.
Nested-band support (`parentBandId`) ports structurally unchanged — already recursive and
unit-agnostic apart from those offsets.
**Data resolution happens inside `layout()`**, where the band's current row is known — so
`PrintDocument` receives finished strings and stays dumb. Call `resolveDBTextValue` and
`evaluateExpression` as plain functions; no React needed. Copy `formatValue` from
[`DBTextField.jsx:11-33`](frontend/src/components/ReportEditor/DBTextField.jsx#L11-L33) into
`a4/formatValue.js` — it is already duplicated verbatim in `ExpressionField.jsx`, so this
consolidates an existing duplicate rather than creating one.
**Pagination** — single pass, deliberately simple:
1. Walk instances in flow order tracking `cursorY` and `pageIndex`.
2. **Band instances are atomic** — never split. If `cursorY + height > contentBottom`, start
a new page and **re-emit every `bandType === 'header'` band at the top**.
3. `footer` bands emit at `contentBottom - height` on **every** page.
4. `summary` emits once, after all detail instances.
5. **Static (non-band) elements emit on page 0 only.** Anything wanted on every page belongs
in a header/footer band — say so in the band-type dropdown's help text.
6. Guard: an instance taller than the content area emits on a fresh page and clips rather
than looping forever.
Returns `{ pages: [[{ el, x, y, text }]] }` — pure, and consumed by both the preview and
`PrintDocument`.
---
## 6. The print path
`PrintDocument.jsx` maps `pages[]` to absolutely-positioned mm HTML:
```jsx
<div className="print-root">
{pages.map((els, i) => (
<div className="a4-page" key={i}>
{els.map(({ el, x, y, text }) => (
<div className="a4-el" key={el.id} style={{
left: `${x}mm`, top: `${y}mm`,
width: `${el.width}mm`,
fontFamily: FONT_STACKS[el.fontFamily],
fontSize: `${el.fontSize}pt`,
fontWeight: el.bold ? 700 : 400,
textAlign: el.align,
}}>{text}</div>
))}
</div>
))}
</div>
```
`print.css`:
```css
@page { size: A4 portrait; margin: 0; } /* landscape variant when set */
.a4-page { width: 210mm; height: 297mm; position: relative;
overflow: hidden; page-break-after: always; }
.a4-page:last-child { page-break-after: auto; }
.a4-el { position: absolute; }
@media print {
body > *:not(.print-root) { display: none !important; }
.print-root { display: block !important; }
}
.print-root { display: none; } /* hidden on screen */
```
`printReport.js` is the non-React entry (so `Main.jsx` can call it directly): take report +
data → `layout()` → render `PrintDocument` into a detached root → `window.print()` → unmount
on `afterprint`. It must work **outside React context** — which is exactly why `layout()` is
pure.
**Save as PDF is the same call.** The operator picks "Save as PDF" (Chrome) or "Microsoft
Print to PDF" as the destination. No second code path.
---
## 7. UI entry points
**Routing: a separate `/report-editor-a4` route, not a `:kind` param.** A param would force
`ReportEditor.jsx` to become a dispatcher — modifying the one file we most want to leave
alone. Two routes, two trees, zero coupling. `React.lazy` the A4 tree so `Main.jsx` doesn't
pay for it.
**Creating:** the load dialog's "+ New Report" opens a two-button choice — *Character matrix
(dot-matrix)* / *A4 page (laser & PDF)* — navigating to the right editor.
**Load dialog:** each editor fetches its own kind (`?kind=a4`), but shows cross-kind reports
in a dimmed "Other reports" group with a badge, clicking which navigates across. Users will
save an A4 report and wonder where it went; ~15 lines prevents that support call.
**Main.jsx Print Notice** becomes kind-aware:
```jsx
const res = await api.get('/api/reports/?name=notice_report'); // uses the new filter
const report = (res.data.results || res.data)[0];
if (report?.kind === 'a4') {
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
await printA4Report(report, vehicleReportData); // layout → PrintDocument → print()
} else {
setShowNoticeReport(true); // existing modal, unchanged
}
```
This also drops the current fetch-all-then-`find` pattern in favour of a server-side filter.
**Inside the A4 editor:** a 🖨 Print button operating on the current in-memory report — no
save required first, which matters for iteration speed while designing.
---
## 8. Verification
### The ruler test — acceptance test for mm correctness
Do this in Phase 4, before building anything on top of the coordinate system:
1. Place a 1 mm hline at **x=20, y=50, width=170** and a text field at **x=20, y=100**
containing `АА1234ВВ` (Cyrillic).
2. Corner crosshairs: 10 mm hlines at (10,10), (190,10), (10,287), (190,287).
3. **Print at 100% / "Actual size"** (not "Fit to page") on real A4. Measure with a steel
rule from the paper edge → 20 mm ± 0.5 mm (printer margin tolerance); top → 50 mm.
4. **Save as PDF** from the same dialog; measure in Acrobat → **20.0 / 50.0 / 170.0 mm**.
5. A consistent offset points at the `@page margin: 0` interaction with the printer's
unprintable area — a page-setup issue, not architecture.
### Fonts — the point of the whole exercise
- Design in **Calibri** and **Segoe UI**; print → the output is really Calibri and Segoe UI,
not a substitute. Compare against the same text in Word if in doubt.
- `АА1234ВВ` renders as Cyrillic in every offered font, **including bold**.
- Save as PDF → **select and copy the text** in the viewer; clipboard must contain
`АА1234ВВ` (proves real text, not raster). Ctrl+F for `1234` must hit.
- Screen and print use the same `font-family` stack → visually identical.
### Print / Save-as-PDF parity
- Same report → Print, then Save as PDF: identical page count and layout.
- With "Headers and footers" **on**, confirm the Chrome header appears (so we know what the
install checklist is preventing); untick it and confirm it is gone.
- Test at "Fit to page" to see the mm error appear — confirms the 100 % requirement is real
and worth documenting.
### Smart guides
Edge-to-edge, centre-to-centre, page-centre and margin snapping each show a guide and snap.
**Zoom to 50 % and 150 % — the grab distance must feel identical** (proves the
px-threshold-through-zoom conversion). Alt suppresses. 100+ elements drag with no dropped
frames in a Performance trace.
### Bands and pagination
- 60 rows × 8 mm = 480 mm → **2 pages**; header band on **both**; footer on both.
- No instance split across a page boundary.
- **On-screen preview page count must equal printed page count** — the one check that proves
the shared `layout()` pipeline really is shared.
- Nested detail + subdetail renders and paginates.
### Matrix regression — mandatory; this is the risk being managed
- Load `notice_report` at `/report-editor`; screenshot-diff against a pre-change capture.
- Add/move/resize/delete each matrix element type; save → reload → unchanged.
- Confirm the payload now carries `kind:'matrix'` and the server stores it.
- Main.jsx → Print Notice with a **matrix** report → old modal, unchanged.
- `python manage.py migrate` against a DB with existing rows → all get `kind='matrix'`,
`page_setup={}`, and still load.
---
## Phases
| # | Scope | Testable at the end |
|---|---|---|
| **1** | Backend `kind`/`page_setup` + migration + filter; `/report-editor-a4` stub | `?kind=a4` filters; existing reports unaffected; route loads |
| **2** | `units.js`, `geometry.js`, `A4Canvas` + CSS, `A4Toolbar`, `A4ReportEditor` save/load, `A4TextField`, `A4ResizeHandles`, `A4Inspector` + `MmInput`, zoom, static margin/centre overlay | Place a field, type exact mm, see it move; drag/resize in mm; zoom preserves positions; save/reload persists floats |
| **3** | `guides.js` + drag integration + overlay + Alt-suppress + arrow nudge | Full guides checklist |
| **4** | `layout.js` (single-page only), `PrintDocument`, `print.css`, `printReport.js`, Print button | **The ruler test.** Calibri prints as Calibri. Save as PDF works |
| **5** | Remaining element types + inspector sections + font controls | A complete single-page notice, on screen and on paper, with live data |
| **6** | `layout()` bands + pagination; `A4Band`, band inspector; preview driven by `layout()` | Multi-page checklist; preview pages == printed pages |
| **7** | Main.jsx dispatch, kind chooser, cross-kind nav, `A4PageSetup`, `React.lazy`, regression sweep | Operator designs → saves → Print Notice → paper; matrix regression passes |
**Phase 4 deliberately precedes the remaining element types.** If exact mm printing can't be
achieved, everything built on top is wasted — so it is de-risked early, on the simplest
document, with a physical ruler.
---
## Risks
| Risk | Mitigation |
|---|---|
| Chrome header/footer, or non-100% scale, breaks mm fidelity | One-time per-station settings; documented in the install checklist and verified in Phase 4 |
| `@page margin: 0` vs the printer's unprintable area clips edge content | Default `page_setup` margins of 15–20 mm keep content well inside; Phase 4 ruler test catches it |
| Customer station lacks a font used in the design | Offered list is Windows-bundled fonts only; every family emitted with a fallback stack |
| Inspector sections drift between matrix and A4 | Accepted deliberately — the price of not touching working code. Note it in the A4 file header |
| Matrix editor regression | It is not modified; the Phase 7 sweep is belt-and-braces |
| Later need for unattended PDF generation | `layout()` is pure and `PrintDocument` emits plain HTML+CSS — POST it to a small Puppeteer service; ~60 lines, no redesign |
@@ -0,0 +1,107 @@
# Plan: Move local dev ports (backend 8000 → 8002, frontend 3000 → 5172)
## User Request
> ok, so, server is set to run on 8000, but I want it to be on 8002, also the frontend -
> what port is it? I want it to be on 5172 (for the local npm rundev)
## Plan
### Context
The local development ports collide with other things running on this machine. The user
wants the Django backend on **8002** instead of 8000, and the React frontend on **5172**
instead of 3000.
Two findings shaped this plan:
1. **The frontend is Create React App (react-scripts 5.0.1), not Vite.** There is no
`dev` script and no `vite.config`. The user asked for "npm run dev" on 5172 — a
Vite-shaped request. Rather than migrate the build tool (out of scope), we set CRA's
`PORT` env var to 5172 and add a `dev` script alias so `npm run dev` works as expected.
2. **Changing the ports is not just two edits.** Three other places hardcode the old
ports and would silently break: the frontend's API base URL, CRA's dev proxy, and the
backend's CORS allow-list (which would reject the frontend's new origin).
Scope is **local dev only**. Docker (`backend/Dockerfile`, `docker-compose.yml`) keeps
port 8000 — it is container-internal there and the host mapping `8101:8000` is what you
actually connect to. Docs (README/SETUP/CLAUDE.md) are left as-is per the user's choice.
### Changes
#### 1. Backend port → 8002
**[run_server.bat:4](../../run_server.bat#L4)** — the only place the local server port is set:
```
uvicorn scalesapp.asgi:application --host 127.0.0.1 --port 8002 --reload
```
#### 2. Frontend port → 5172 and API URL → 8002
**`frontend/.env`** (currently one line, `REACT_APP_API_URL=http://localhost:8000`):
```
PORT=5172
REACT_APP_API_URL=http://localhost:8002
```
`PORT` is the standard CRA dev-server port variable — no config file needed. Note CRA
only reads `.env` at dev-server start, so the server must be restarted, not just reloaded.
**`frontend/.env.example:1`** — mirror the same two lines so the template stays accurate.
#### 3. `npm run dev` alias
**[frontend/package.json:13-18](../../frontend/package.json#L13-L18)** — add a `dev`
script next to the existing `start`:
```json
"start": "react-scripts start",
"dev": "react-scripts start",
```
#### 4. CRA dev proxy → 8002
**[frontend/package.json:36](../../frontend/package.json#L36)**:
```json
"proxy": "http://localhost:8002"
```
#### 5. CORS allow-list → add 5172
**[backend/scalesapp/settings.py:108](../../backend/scalesapp/settings.py#L108)** —
without this the browser blocks every API call from the new origin:
```python
_cors_default = 'http://localhost:5172,http://127.0.0.1:5172,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174'
```
Existing 3000/5174 entries are kept — they cost nothing and avoid breaking any other
local setup. This is only the *default*; an explicit `CORS_ALLOWED_ORIGINS` in
`backend/.env` would override it, and there is currently none, so the default applies.
### Not changed (deliberately)
- `backend/Dockerfile`, `docker-compose.yml` — container-internal 8000, host port 8101.
- `frontend/src/services/api.js:3`, `contexts/NomenclatureContext.jsx:3` — their
`http://localhost:8000` is a fallback only, superseded by `REACT_APP_API_URL`.
- `serial_bridge/` — runs on 5000, talks to Django only via `BACKEND_URL`; unaffected
unless the bridge needs to reach the backend (it currently pushes nothing on startup).
- README.md, SETUP.md, CLAUDE.md port references.
### Verification
1. `run_server.bat` → console shows `Uvicorn running on http://127.0.0.1:8002`.
Confirm `http://127.0.0.1:8002/api/` responds (401/403 is fine — it proves it's up).
2. `cd frontend && npm run dev` → console shows `On Your Network: http://localhost:5172`
and the browser opens there.
3. Log in through the UI. This is the real end-to-end check: it exercises
`POST /api/token/` cross-origin from `localhost:5172` to `localhost:8002`, so it
validates the API URL **and** the CORS entry together.
4. Open DevTools → Network: no CORS errors, and the `/sse-connect/` EventSource shows
status 200 and stays open (SSE is authenticated via query-param token and is the most
likely thing to break silently if a port was missed).
5. Confirm nothing still calls 8000: with the app running, check the Network tab for any
request to `localhost:8000`.
+88
View File
@@ -0,0 +1,88 @@
# CSS design tokens — typography, spacing, and full colour migration
## Context
`src/styles/tokens.css` already defines semantic colour/radius/shadow tokens for
two themes (`classic`, `cockpit`). But:
- there are no font or spacing tokens: ~25 distinct font-sizes (7px…40px, px and
rem mixed), 9 font-family declarations, ~25 distinct padding/margin/gap values;
- about half the stylesheets still hardcode colours (`#667eea` ×88, `#333` ×42 …),
so those screens ignore the theme — they stay light under `cockpit`;
- `UserManager.jsx` styles itself entirely with inline `style={{}}`.
Goal: every UI value (colour, font, size, weight, spacing) comes from a token, so
the look is changed in one file. Report *output* keeps fixed values.
## What stays fixed (not tokenised)
Printed/rendered report output must look identical regardless of screen theme:
- `ReportEditor/a4/a4Elements.css`, `a4/print/print.css`, `ReportEditor/print/print.css`,
`ReportEditor/elements.css` (text-mode report elements on the page)
- colours in A4 element JSX (`a4/elements/*`), `calibrationReport.js`, `sampleData.js`, tests
- the "paper" surface itself (white page in `A4Canvas.css` / `EditorCanvas.css`)
The report **editor chrome** around it (toolbar, inspectors, config panel, character
palette, kind chooser, page setup, selection handles/guides) *does* get tokens.
Also left alone: `components/concepts/ScalesAppCyberpunk.css` (standalone mockup
with its own private vars). `DataDisplay.jsx/.css` is not imported anywhere — dead
code, flagged, not touched.
## New tokens (in `:root` only — theme-independent, both themes share them)
```css
/* Type */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Courier New', Consolas, monospace;
--fs-xs: 11px; --fs-sm: 12px; --fs-base: 13px; --fs-md: 14px;
--fs-lg: 16px; --fs-xl: 18px; --fs-2xl: 22px; --fs-3xl: 28px;
--fs-display: 32px; /* big figures: the weight reading etc. */
--fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700;
/* Spacing — 4px grid with the half-steps this UI actually uses a lot (6, 10) */
--space-0-5: 2px; --space-1: 4px; --space-1-5: 6px; --space-2: 8px;
--space-2-5: 10px; --space-3: 12px; --space-4: 16px; --space-5: 20px;
--space-6: 24px; --space-8: 32px; --space-10: 40px;
```
A theme may later override any of these (e.g. a larger `--fs-display` for cockpit).
**Snapping rule:** off-scale values snap to the nearest step (5→4/6, 7→6/8,
14→12/16, 15/17→16, 20→22 for font, rem values converted at 16px). Expect ≤2px
drift in places — that's the consistency we're buying. Values that are
geometry rather than rhythm (1px borders, icon/checkbox sizes, fixed widths,
`letter-spacing`) stay literal.
**Colour mapping** for stray literals (add a token only if nothing fits):
`#667eea`→`--accent`, `#764ba2` gradients→`--accent-gradient`,
`#333/#2c3e50`→`--text`, `#555/#666/#888/#999`→`--text-muted`,
`#ddd/#e0e0e0/#dee2e6`→`--border`, `#f5f5f5/#fafafa/#f0f0f0`→`--surface-sunken`/`--surface-hover`,
`white/#fff`→`--surface`, `#fee/#c33`→`--danger-soft`/`--danger`, greens/oranges→`--success*`/`--warning*`.
Likely one new token: `--backdrop` (modal overlay dim).
## Phases (one commit each; done via `/dispatch` → react-expert-dev)
1. **Tokens** — add the block above to `tokens.css`; `index.css` body uses
`--font-sans`, `code` uses `--font-mono`.
2. **Screens already on colour tokens** — swap font/spacing literals:
`Main.css`, `ScaleMonitor.css`, `Header.css`, `History.css`,
`ExtraCombinationsTable.css`, `App.css`, `ReportPreviewModal.css`.
3. **Hardcoded screens** — colours + fonts + spacing:
`NomenclatureManager.css`, `NomenclatureUI/*.css` (ManagementOverlay, EntryForm,
Dropdown, FormOverlay), `LicenseManager.css`, `Login.css`, `ChangePasswordOverlay.css`.
`UserManager.jsx`: move inline styles into a new `UserManager.css` (reuse existing
Main.css classes like `.extra-field`, `.vehicle-form-actions` where they already fit).
4. **Report editor chrome** — `ReportEditor.css`, `Toolbar.css`, `ObjectInspector.css`,
`ConfigPanel.css`, `CharacterPalette.css`, `EditorCanvas.css` (non-paper parts),
`a4/A4Canvas.css` (non-paper), `A4Inspector.css`, `A4PageSetup.css`,
`A4KindChooser.css`, `A4ReportEditor.css`; chrome colours in `A4Canvas.jsx` /
`A4Inspector.jsx` / `ObjectInspector.jsx` (guides, handles) → `var(--…)`.
## Verification
- After each phase: grep for `#[0-9a-f]{3,6}`, `font-size: *[0-9]`, px spacing in the
touched files → only allowed literals remain.
- End state: hex colours exist only in `tokens.css`, the "fixed" report files, and the
concept page.
- `npm run build` passes.
- Visual: screenshots (Playwright, saved to `.claude/screenshots/`) of main screen,
nomenclature manager, login, licence, user manager, both report editors — in
`classic` and `cockpit`, before and after. Classic should look the same (±snapping);
cockpit should now be dark on the previously-light screens.
- Test suite only when you say "ready for testing".
@@ -0,0 +1,321 @@
# 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 `<th>` 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 `#<id>` 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
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<div className="extra-fields">
```
**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 `#<id>` (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 `#<id>` 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`.
+451
View File
@@ -0,0 +1,451 @@
# Finalize-on-print, History view, and unified report preview modal
## Context
Today, the moment a vehicle's second weighing lands, the backend silently
computes `net` and assigns a `doc_number` (`_maybe_finalize`,
[api/views.py:134-141](backend/api/views.py#L134-L141)) — but nothing in the
UI reflects this as an event. The vehicle just sits in the active list
forever, indistinguishable from an in-progress one except that its action
column now shows Print/Design instead of Measure.
The user's actual workflow (matching their existing Delphi weighing software,
and the problem it has that they want to avoid repeating): once a truck is
weighed twice, the operator opens a report preview, prints or downloads the
notice, and the truck should then leave the active list — because it's done,
and a busy station's list should only show trucks still being processed. If a
print physically fails (paper jam, printer offline — a real, frequent
occurrence per the user, and one the browser's `window.print()` can never
report back on, confirmed via `afterprint`'s fundamental limitation), the
operator needs to get back to that vehicle's report without digging through a
separate/hidden screen. A **History** view listing finalized vehicles, with
the same print/download controls available there, is exactly that recovery
path — and it becomes the *only* path back once a vehicle leaves the active
list, so it must be trivial to reach, not buried.
This session's exploration surfaced two things that must be fixed as part of
this work, not deferred:
1. **`driver_name` is dead on arrival.** The live vehicle API is
`api/views.py` + `api/serializers.py` (registered in
`api/urls.py`) — NOT `vehicles/views.py`/`vehicles/serializers.py`, which
are orphaned (not routed) and contain the `driver_name`-required work from
the previous session. `driver_name` is missing entirely from
`VehicleSerializer.Meta.fields` in the real, live
`api/serializers.py:165`. The frontend's create-vehicle form already sends
`driver_name`, but the live backend silently drops it on the floor.
2. **The frontend never removes anything from `vehicles`, and SSE has no
working delete path.** `NomenclatureContext.handleSSEUpdate`
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432))
only handles `insert`/`update`; a `delete`/`archive` operation is silently
dropped even though the backend's `operation_map` already produces
`'delete'` for a `'deleted'` action. Any "vehicle leaves the list in real
time, on every open tab" behavior needs this added — it is not
pre-existing plumbing we can just call.
## Decisions made this session (do not re-litigate)
- **driver_name**: nullable at the DB level (already migrated), **required on
create** at the serializer level — fix belongs in `api/serializers.py`
(the live one), same validation approach already proven correct in the
orphaned `vehicles/serializers.py` (object-level `validate()`, gated on
`self.instance is None`, because a per-field `validate_driver_name` hook
was tested and found to skip validation when the key is omitted entirely).
- **When a vehicle leaves the active list**: on the operator's explicit
**Print or Download PDF click** inside the new preview modal — not
automatically the moment `net`/`doc_number` are assigned server-side. The
user was explicit: "when press it - the truck is given a document number,
disappears from list, the preview opens with print/download buttons."
Concretely: the modal opens as soon as Print Notice is clicked (same
trigger as today), and the archive+doc_number-visible transition happens
when the operator acts inside that modal (Print or Download PDF), not on
the initial open. Opening the modal must not, by itself, remove the truck —
an operator who opens it and then closes it without printing must find the
truck still there, still able to re-measure if needed.
- **Preview modal layout** (replaces `ReportPreviewModal`'s current bare
preview-only body): top bar with a matrix/A4 kind switcher on the left and
Download PDF on the right; body renders the selected kind's preview; footer
bar with centered Print and Close buttons. **The switcher is real, not a
placeholder**: `notice_report` can have two actual saved `Report` rows
(one `kind='matrix'`, one `kind='a4'`) side by side — confirmed the model
already allows this (`name` has no uniqueness constraint) — and the
switcher toggles between whichever of the two the operator has designed.
If only one kind has been saved, the other option shows a disabled/empty
state pointing at Design Notice for that kind. Default kind shown on open =
the kind that was open/used last, or the first one that exists.
- **Download PDF: REVERSED from an earlier draft of this plan** — no
server-side rendering. Originally scoped as Playwright (Python) driving
headless Chromium; abandoned once an existing plan
([a4-report-designer.md](.claude/plans/a4-report-designer.md), §"Why
Puppeteer is deferred (not rejected)") surfaced two concrete costs specific
to this deployment that the earlier draft hadn't weighed:
1. **Fonts.** A4 notices print with whatever font is installed on the
*operator's station* — that's the whole point of using
`window.print()`/`@page` CSS instead of a PDF library (see that same
plan's "Why browser printing, not a PDF library"). Server-side
rendering uses the *server's* fonts instead. At this project's actual
reference deployment (one server, ten stations, per
[[deployment-windows-installer]]), that's the wrong machine — a
server-rendered PDF could visibly mismatch what prints at the station,
forever, unless fonts are duplicated and kept in sync server-side.
2. **Puppeteer/Playwright earns its cost when a PDF must be produced with
no human in the loop** (auto-archiving, emailed tickets, batch jobs).
Download PDF has an operator clicking a button and waiting — exactly
the case `window.print()` → "Save as PDF" already covers, today, with
zero new services and zero font-mismatch risk.
**Decided approach**: Download PDF triggers the SAME `window.print()` flow
already used for Print (`printA4Report` for a4, the new print-root-append
flow from Phase C for matrix), just labelled/framed toward the "Save as
PDF" destination rather than physical printing. No Playwright, no
Puppeteer, no new backend endpoint, no offline-installer packaging cost.
This also means Phase D as originally scoped (a new PDF microservice) is
**not needed** — see the rewritten Architecture section below.
- **Print button** in the modal (and Download PDF, per the reversal above)
both use today's `window.print()`/`printA4Report()` flow for A4, and the
Phase C print-root-append flow for matrix. No further backend work.
- **History view**: a new page/route, reachable from the main nav (not
hidden), listing finalized vehicles (those with `doc_number` set) — most
recent first. Each row opens the same unified preview modal used from the
main weighing screen, so reprint/redownload is the identical component and
code path, not a second implementation.
- **Nothing is ever deleted.** Explicit, standing rule for this feature and
beyond: a vehicle record is never destroyed. There are three real states —
**in progress** (active list), **completed** (History, has `doc_number`),
and **voided** (History, flagged but still fully visible/queryable) — and
moving between them is always a flag/field change, never a `DELETE`. This
also means the orphaned `vehicles/views.py`'s `perform_destroy`
([confirmed at api/views.py's pattern](backend/api/views.py) — check
whether `VehicleViewSet` exposes a working DELETE at all; if it does, it is
now out of step with this rule and should be disabled, flagged to the user
rather than silently left reachable).
- **Void ("сторно"/reversal)**: an operator-triggered action available **only
from History, only on an already-completed record** — not on an
in-progress vehicle, and not a bulk/automatic thing. Marks a completed
weighing as voided when it turns out to be wrong (wrong truck, bad
measurement, mistake caught after printing) — the record, its doc_number,
and its full weighing history stay intact and visible in History,
permanently flagged, distinguishable from a normal completed record at a
glance (e.g. a visual badge, per how `.vehicle-status--*` already
distinguishes states in the hero bar,
[Main.css:189-203](frontend/src/components/Main.css#L189-L203)).
- **`vehicle_number` uniqueness must change.** Today it's a plain
`unique=True` column ([vehicles/models.py:11](backend/vehicles/models.py#L11))
— global, forever. Combined with "nothing is ever deleted," this breaks the
very next time a real truck (plate reused constantly) returns to the scale
after its first weighing completes: creating the new active record would
fail on a uniqueness violation. Decided fix: **unique only among
in-progress vehicles** — a truck can't be on the scale twice at once, but
its plate can appear any number of times across History. Implemented as a
Django partial `UniqueConstraint`, not a plain column constraint:
```python
class Meta:
constraints = [
models.UniqueConstraint(
fields=['vehicle_number'],
condition=models.Q(doc_number__isnull=True),
name='unique_active_vehicle_number',
),
]
```
(Django 6.0, confirmed installed — `condition=` on `UniqueConstraint` has
been supported since Django 2.2, compiles to a PostgreSQL partial unique
index, which this project's Postgres backend fully supports.) Requires
dropping the existing `unique=True` on the field itself and adding this
constraint instead — a real migration, not a no-op. Confirm during
implementation whether "in-progress" should be defined as
`doc_number__isnull=True` alone, or also exclude voided-but-somehow-
doc_number-less edge cases — should not arise if void is only ever
triggered on already-completed (doc_number-bearing) records, per the
Void scope decision above, but worth a sanity check against real data.
## Architecture
### Backend
**1. Fix the live serializer** — `backend/api/serializers.py`:
- Add `'driver_name'` to `VehicleSerializer.Meta.fields` (currently missing
at line 165).
- Add the required-on-create `validate()` override, ported from the pattern
already proven in the orphaned `vehicles/serializers.py`:
```python
def validate(self, attrs):
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs
```
(If `VehicleSerializer` already defines a `validate()`, merge into it
rather than adding a second one.)
- Leave the orphaned `vehicles/` app alone — it's unrouted dead code, not
this task's problem to clean up (flag to the user separately; don't delete
without being asked, per project convention).
**2. No PDF endpoint needed — REVERSED, see the Download PDF decision above.**
No new backend endpoint, no Playwright, no server-side rendering. Phase D on
the backend side is a no-op; everything below is frontend-only.
**3. Print-intent signal for both the modal's Print AND Download PDF
buttons**: both now trigger the *existing* client flow
(`window.print()`/`printA4Report` for a4, the Phase C print-root-append flow
for matrix — Download PDF is the same call, just aimed at the "Save as PDF"
destination the operator picks in that same dialog), which the backend can't
observe directly.
Add a lightweight endpoint, e.g. `POST /api/vehicles/{id}/mark-notice-printed/`,
called by the frontend right when the operator clicks Print in the modal (not
waiting on `afterprint`, since that signal is untrustworthy per this
session's earlier discussion) — its only job is to broadcast the SSE removal
event (below). It does not need to change any DB field; `doc_number` already
being set is the source of truth for "this vehicle belongs in History, not
the active list." This keeps "print succeeded" and "should leave the active
list" cleanly decoupled, exactly as discussed: a failed physical print still
correctly moves the truck to History (where it's one click to reprint), it
does not silently strand it in a state nobody can find.
**4. SSE removal broadcast**: wherever the "moved to history" signal fires
(the `mark-notice-printed` endpoint, and/or the new PDF endpoint on
successful generation), call
`sse_broadcast_update('vehicle', 'archived', serializer.data)` — a new,
distinct action string (not reusing `'deleted'`, since the vehicle record
itself isn't deleted, only its presence in the *active* list changes) — see
frontend section for how this action is consumed. Using
[sse_broadcast_update](backend/scalesapp/sse.py#L26-L58), same call shape
already used by `perform_create`/`perform_update`.
**5. Filter the active-list endpoint** (optional but recommended for
correctness/scale): today `VehicleViewSet.queryset` returns all vehicles
unconditionally. Once History exists, the main screen's vehicle list should
probably query only non-finalized vehicles server-side too (e.g.
`?doc_number__isnull=true`), not rely purely on client-side SSE-driven
removal — SSE gives real-time updates for tabs already open, but a **fresh
page load** should not have to fetch every historical vehicle ever weighed
just to filter them out client-side. Add a query param the frontend's main
list fetch already passes (`NomenclatureContext`'s vehicle endpoint config,
[NomenclatureContext.jsx:9](frontend/src/contexts/NomenclatureContext.jsx#L9)),
and a separate History fetch that explicitly requests
`?doc_number__isnull=false`, ordered newest-first (`-net_date` or similar).
**6. `is_void` field + partial-uniqueness migration** — the `Vehicle` model
itself lives at `backend/vehicles/models.py` regardless of which app's
views/serializers are live (there is only ONE `Vehicle` model — `vehicles/`
being "orphaned" refers only to that app's OWN `views.py`/`serializers.py`,
not the model, which `api/views.py` imports and uses directly). So this
migration correctly targets `backend/vehicles/models.py`, but the
`@action`/serializer changes below belong in `api/views.py`/`api/serializers.py`
(the live ones), matching how `driver_name` and everything else in this plan
is split: model in `vehicles/`, live API surface in `api/`.
- Add `is_void = models.BooleanField(default=False)`, plus
`void_date`/`void_user`/`void_reason` following the same
date/user/note provenance pattern already used for `tare`/`gross`/manual
entries elsewhere on this model (e.g. `tare_manual_user`,
`tare_manual_date`, `tare_manual_note`,
[vehicles/models.py:19-22](backend/vehicles/models.py#L19-L22)) — reuse
that established shape rather than inventing a different one for void.
- Replace `vehicle_number = models.CharField(max_length=15, unique=True)`
with the plain field (drop `unique=True`) plus the partial
`UniqueConstraint` from the decision above, in one migration.
- New `@action` on `VehicleViewSet`, e.g.
`POST /api/vehicles/{id}/void/` (mirroring `measure`'s permission-gated
pattern) — sets `is_void`, `void_date`, `void_user`, accepts a required
`reason` in the request body (store it; an operator voiding a printed
document should always leave a reason, same spirit as
`gross_manual_note`/`tare_manual_note` already require context for a
manual override). Rejects (400) if `doc_number` is not set — void only
applies to completed records, per the scope decision above. This does NOT
set `doc_number` to null or touch `net`/`tare`/`gross` — everything about
the original weighing stays exactly as it was, only `is_void` changes.
Broadcasts `sse_broadcast_update('vehicle', 'voided', serializer.data)` so
any open History view updates live too.
- `VehicleSerializer` needs `is_void`/`void_date`/`void_reason` added to
`Meta.fields` (read-only except through the dedicated void action).
### Frontend
**1. `NomenclatureContext.jsx`** — add a `delete`/`archive` branch to
`handleSSEUpdate`'s switch
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432)),
handling the new `'archived'` action (mapped through `operation_map` on the
backend, or handled as a raw fallback string if not added to that map — check
which is simpler when implementing) by removing the vehicle from the
in-memory `vehicles` array. This is the one piece of "real-time removal"
plumbing that doesn't exist anywhere today and is the crux of making
multi-station "truck vanishes everywhere at once" actually work.
**2. Unified `ReportPreviewModal`** — restructure
[ReportPreviewModal.jsx](frontend/src/components/ReportPreviewModal.jsx) (and
its `.css`) into three regions instead of the current bare header+body:
- **Top bar**: left = matrix/A4 toggle, switching between two genuinely
separate stored `Report` rows sharing the same `name='notice_report'` but
different `kind`. Confirmed this needs no schema change: `Report.name` has
**no uniqueness constraint**
([api/models.py:45-75](backend/api/models.py#L45-L75)), so a matrix row and
an a4 row can already coexist under the same name — nothing stops it today.
The gap is entirely on the fetch/lookup side:
- `fetchNoticeReport()` ([Main.jsx:357-360](frontend/src/components/Main.jsx#L357-L360))
currently does `.find(r => r.name === 'notice_report')`, returning
whichever row the API lists first — it has no way to ask for "the matrix
one" vs "the a4 one." Change it to accept a `kind` argument and filter
accordingly (or add a backend query param, `?name=notice_report&kind=a4`,
since `ReportViewSet` already has `filterset_fields = ['name', 'kind']`
— the backend filter already exists, this is a client-side fix only).
- The modal fetches BOTH kind-variants on open (two lookups, or one list
call filtered by name only, then split client-side by `kind`), so the
switcher can toggle without a re-fetch. If one kind doesn't exist yet
(operator never designed an A4 version), that switcher option is
disabled/shows an empty state with a link to Design Notice for that kind.
- `handleDesignNotice`/`handleDesignAs`
([Main.jsx:403-449](frontend/src/components/Main.jsx#L403-L449)) already
let an operator explicitly open either editor regardless of what's saved
(`handleDesignAs('matrix'|'a4')`) — this already supports creating the
*other* kind's report under the same name; the Report Editor's own save
path (`handleSave`,
[ReportEditor.jsx:276-297](frontend/src/components/ReportEditor/ReportEditor.jsx#L276-L297))
does a plain `POST` when `reportId` is unset, so saving a second,
different-kind `notice_report` is already a supported write path with no
backend changes needed there either.
Right = Download PDF button — **REVERSED, see the top-level decision
above**: no backend endpoint, no blob download. Triggers the same
`window.print()`/`printA4Report` (a4) or print-root-append (matrix) flow
as the Print button, so the operator reaches PDF via "Save as PDF" in that
same dialog, using the station's own installed fonts. (Phase C shipped
this button as a disabled placeholder before this reversal was decided —
Phase D's remaining frontend work is wiring it to the real print flow
instead of leaving it disabled, plus the `MatrixPrintDocument` work above.)
- **Body**: existing `EditorCanvas`/`DataProvider`/`BandProvider` preview for
matrix; for A4, mount `PrintDocument` (confirmed stateless/reusable,
[PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
scaled into the modal body instead of only appending it to `document.body`
for `window.print()` — needs a scaled/contained rendering mode, not the
full-bleed print-root mounting `printReport.jsx` uses today.
- **Footer bar**: centered Print + Close. Print triggers the kind-appropriate
existing print flow (`printA4Report` for a4; for matrix, there is currently
no equivalent "just print this" path — `ReportPreviewModal` today is
preview-only with no print button at all, so a matrix print path needs to
be added, likely also via `window.print()` on the rendered preview DOM, or
reusing the same print-root-append pattern `printReport.jsx` established).
Then calls the new `mark-notice-printed` endpoint.
- Replace `Main.jsx`'s current branching logic
([handlePrintNotice, lines 375-398](frontend/src/components/Main.jsx#L375-L398))
so BOTH kinds open this one modal (today only matrix does; a4 skips the
modal entirely and goes straight to `window.print()`) — this is the biggest
behavioral change to `handlePrintNotice`: it becomes "always open the
modal," full stop, with the modal itself deciding what Print/Download do
based on the loaded report's kind.
**3. New History page** — new route (e.g. `/history`) added alongside the
existing routes in
[App.jsx](frontend/src/App.jsx) (inside the same provider nest, following the
existing route-gating pattern used for admin-only pages). Fetches finalized
vehicles (`?doc_number__isnull=false`, newest first) independently of the
live `vehicles` array (History should NOT depend on `NomenclatureContext`'s
in-memory list, since that list is specifically the *active* set now).
List each with vehicle number, doc number, net weight, date, and a
completed/voided badge (`is_void`); clicking a row opens the same unified
`ReportPreviewModal`, passing that vehicle's data exactly as `Main.jsx` does
today via `vehicleReportData`
([Main.jsx:313-334](frontend/src/components/Main.jsx#L313-L334) — this
transform logic is reusable, likely worth extracting to a shared helper
rather than duplicating in the new History component). Print/Download PDF
stay available on a voided row too — voiding never removes the ability to
see or reproduce the original document, it only flags that the weighing
itself shouldn't be treated as valid.
- **Void button**: on a completed (non-voided) row, a distinct action from
opening the preview — requires a reason (matches the backend's required
`reason` field), then calls the new `/void/` endpoint. On success, the row
updates in place to show the voided badge; it does not disappear or move
anywhere (History is a flat list of everything that ever completed,
voided or not). Confirm with a dialog before voiding — this is meant to be
rare and deliberate, matching the "сторно/reversal" concept's own
seriousness (it's correcting the record, not casually undoable further).
- A nav entry for History (in `Header.jsx`, alongside whatever nav already
exists) — must be easy to find, per the whole point of this feature being
"don't bury the recovery path."
## Open questions to resolve during implementation
Phase D, resolved: no Playwright/Puppeteer, no server-side PDF endpoint (see
the Download PDF decision above). The matrix print path question is already
resolved too — Phase C built it (print-root-append, following
`printReport.jsx`'s pattern, confirmed working).
**Remaining open item, genuinely new scope surfaced while designing Phase D**:
1. **Matrix has no `PrintDocument`-equivalent.** A4's `PrintDocument.jsx`
([print/PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
is a small (107-line), stateless, non-interactive component that reads
`layout()`'s pure `pages[]` output and renders plain positioned HTML — no
hooks, no drag/select/delete callbacks. Its own doc comment already
anticipated exactly this kind of reuse: *"DELIBERATELY DUMB... that is
what lets a future Puppeteer service consume this exact markup."* Matrix
has no equivalent: its preview goes through `EditorCanvas`
([EditorCanvas.jsx](frontend/src/components/ReportEditor/EditorCanvas.jsx),
682 lines), the SAME interactive editor component used for actual
editing, gated by a `previewMode` prop — confirmed by reading it that
`previewMode` is a flag threaded through the interactive tree, not a
separate rendering path; every element still gets full
`onSelect`/`onUpdate`/`onDelete`/`onDragStart` callback props even in
preview mode. Phase C's matrix print-root flow works today only because
it reuses this same `EditorCanvas`-in-`previewMode` machinery — it works,
but it's borrowing an editor component for a job a pure renderer should
do.
- **Decided**: build a `MatrixPrintDocument`-equivalent as part of this
phase — a pure, non-interactive component mirroring what
`PrintDocument.jsx` already does for A4, reading matrix `elements` +
resolved band/data output and emitting plain markup. This is real,
bounded new work (not a redesign of anything existing), and it's a
prerequisite for the matrix print/preview path to reach the same
quality bar A4 already has — it does NOT unlock server-side PDF
generation (that's still explicitly out of scope, per the reversal
above); it's purely about correctness/simplicity of the existing
client-side matrix print flow, replacing "borrow the editor" with "use
a real preview renderer," same shape as A4.
## Verification
1. Create a vehicle without `driver_name` via the API directly (curl/Postman)
— confirm it's now rejected with a 400 and the field-specific error,
where today it would silently succeed with `driver_name` dropped.
2. Weigh a vehicle twice (tare + gross) in the browser — confirm it still
shows in the active list (finalization alone must not remove it).
3. Click Print Notice — confirm the unified modal opens (for BOTH a matrix-
and an a4-kind `notice_report`, test each), with top bar (kind
switcher + Download PDF), body preview, footer (Print + Close centered).
4. Click Close without printing — confirm the vehicle is still in the active
list (opening the modal alone must not archive).
5. Click Print — confirm `window.print()`/`printA4Report` still works exactly
as before, AND the vehicle disappears from the active list within ~1s on
the SAME tab and on a SECOND open tab (real-time SSE removal across
stations).
6. Click Download PDF instead — confirm it opens the same `window.print()`
dialog as Print (with "Save as PDF" available as a destination, per the
reversed decision — no direct blob download, no bypass of the OS dialog),
and the vehicle is similarly removed from the active list once triggered.
7. Open History — confirm the just-archived vehicle appears, newest first,
with correct doc_number/net/date.
8. Click that History row — confirm the same modal opens and Print/Download
both work again (the reprint path this whole feature exists to guarantee).
9. Confirm a vehicle still mid-weighing (no `gross` yet) never appears in
History.
10. **Reuse a plate**: complete a weighing for `B7898TP`, confirm it archives
to History, then create a brand-new active vehicle also named `B7898TP`
— confirm this succeeds (no uniqueness error), proving the partial
constraint is scoped correctly. Then try creating a SECOND simultaneous
active `B7898TP` while the first is still in progress — confirm this is
rejected (proving uniqueness is still enforced among active records).
11. From History, void a completed record without providing a reason —
confirm it's rejected. Void it with a reason — confirm the row updates
in place with a voided badge, doc_number/net/tare/gross are unchanged,
and Print/Download still work on it afterward.
12. Attempt to void an active (non-completed) vehicle directly via the API —
confirm it's rejected (void is completed-records-only, per scope).
13. Confirm nothing in this feature ever issues a DELETE against a vehicle.
**Done, not just checked**: `VehicleViewSet.destroy()` (the LIVE one in
`api/views.py` — found reachable via `IsAuthenticated` alone, worse than
the orphaned `vehicles/views.py` this item originally worried about) now
unconditionally returns `405` pointing at `/void/` instead. Verify with a
direct `DELETE /api/vehicles/{id}/` call that it's rejected and the
vehicle still exists afterward.
14. **Matrix print quality**: with the new `MatrixPrintDocument`, print (and
Download-as-PDF-via-print) a matrix `notice_report` — confirm the output
matches what the old `EditorCanvas`-in-`previewMode` flow produced
(no regression), and confirm the print/preview path no longer carries
editor-only affordances (no residual selection/drag artifacts, since a
non-interactive component can't have any).
+185
View File
@@ -0,0 +1,185 @@
# Licensing: Server-Side Node-Locked with Station Limit
**Status: BUILT and verified, 2026-09-17.** This document is the record of what exists, not a proposal.
## Context
Customers buy ScalesApp for a fixed number of weighbridge stations. Without enforcement, nothing stops a customer copying the installer onto more machines. Sites are **air-gapped** (no internet on the scales server), so any online activation or phone-home scheme is impossible.
This licences the **server**, enforcing a maximum number of concurrently connected stations. Bridges themselves stay unlicensed.
**Decisions made with the user:**
- **Licence the server, not each bridge** — one fingerprint, one key per customer, instead of N of each.
- **Count = concurrently connected bridges.** Licence says 5; a 6th is declined. If one of the 5 stops, its slot frees automatically.
- **Enforcement is per-station**: the 6th is refused, the working 5 are never interrupted.
- **Tolerant fingerprint + 14-day grace period** on hardware change.
- **Perpetual** — no expiry date (the signed payload carries an optional `expires` field so subscriptions can be added later without changing the format).
- Slot refresh every 30s, released after 2 minutes (4 missed refreshes).
---
## Threat model — stated plainly
Client-side licensing **can always be defeated** by someone determined; the server is a Python app and bytecode is inspectable. The goal is not "unbreakable":
- Honest customers stay honest; the limit is a clear contractual line
- Casual copying (operator copies the folder to 3 more PCs) is stopped
- A determined pirate with a cracker is **not** stopped
For B2B sold to a port with a support contract, that is the right level. Do not invest in obfuscation — it raises effort, never to infinity, and costs debuggability.
---
## How it works
### Signing, not a shared secret
The critical design point: **the application only ever verifies; it can never generate.**
1. Server computes a **machine fingerprint** from stable hardware IDs
2. Customer sends that string (email, phone, photo — it is short)
3. Vendor runs `tools/license_tool.py sign`, which signs `{customer, stations, components}` with the **Ed25519 private key**
4. Customer pastes the key back; the server verifies with the **public key** compiled into `api/licensing.py`
A shared-secret or same-algorithm-both-ways scheme would be worthless — anyone who unpacks the app could mint keys. With signatures, forging requires the private key, which never leaves the vendor's machine.
Blob format: `base64("<payload json>.<base64 signature>")`.
### Tolerant fingerprint
Four components are collected and hashed **separately** (not combined), so partial matching is possible: motherboard serial, disk serial, first physical MAC, machine GUID.
Validation passes when **at least 3 of 4** match (`MIN_COMPONENT_MATCHES`). A replaced disk or NIC keeps working; a wholly different PC fails.
**Windows uses PowerShell CIM, not `wmic`.** This matters: `wmic` is deprecated and **has been removed from recent Windows 11 builds**, where it fails silently and yields nothing. On this development machine the original `wmic` approach found only **1 of 4** components, which would have made every licence fail to validate. Each lookup falls back to `wmic` so older machines still work. Linux reads `/sys/class/dmi`, `lsblk`, `/sys/class/net` and `/etc/machine-id`.
Missing components return `None` rather than raising — a VM or restricted environment may not expose all four, and the 3-of-4 rule tolerates that.
### Grace period
If validation fails (fewer than 3 components match, or no licence at all), the server enters a **14-day grace period**: everything keeps working, a persistent non-dismissible banner appears for all users, and the admin licence screen explains it. After 14 days the limit drops to **1 station** (`UNLICENSED_STATIONS`).
Rationale: a licence check must never stop a weighbridge at 3am. Grace turns a hard outage into a support call.
Grace start is stored in the **database**, not a file — a file is trivially reset, and the DB is included in backups.
---
## Counting connected stations
The bridge **deliberately never talks to Django** — that independence is a design decision (see `bridge-is-transport-only` in project memory). Licensing does not change it.
**The browser reports instead.** It already talks to both. On load and every 30s it reads the bridge's `/status` and POSTs a heartbeat:
```
POST /api/license/heartbeat/ {"station_key": "st-abc123", "com_port": "COM3", "bridge_version": "1.1.0"}
-> 200 {"allowed": true, "stations_used": 3, "stations_licensed": 5}
-> 403 {"allowed": false, "detail": "Licence limit reached (5 stations)"}
```
`station_key` is a short stable hash of the bridge address plus its COM port, so **two browser tabs on the same station share one slot** rather than consuming two.
`StationSlot` rows are live if `last_seen` is within `SLOT_TIMEOUT_SECONDS` (120).
**A reconnecting station reclaims its own slot even at the limit** — matched on `station_key` in `_station_allowed()`. This is the single most important rule in the counting logic: without it, a station that blipped would be locked out by its own expiring ghost, which would look exactly like a licensing bug at the worst possible moment.
### What "declined" means
The 6th station's Measure button is disabled with the reason shown beneath it, and `POST /api/vehicles/{id}/measure/` returns 403 for that station key. Viewing and editing continue normally. The other five are entirely unaffected.
**The API check is the rule; the button state is only convenience.** Enforcement lives in `VehicleViewSet.measure`, not the UI.
---
## What was built
### Backend
| File | Contents |
|---|---|
| `api/licensing.py` | `machine_components()`, `machine_fingerprint()`, `verify_license()`, `license_status()`, `effective_station_limit()`, `LICENSE_PUBLIC_KEY` |
| `api/models.py` | `License` (single row, enforced in `save()`), `StationSlot` |
| `api/views.py` | `LicenseView` (GET status / POST install), `LicenseHeartbeatView`, `_station_allowed()`, licence check in `measure` |
| `api/urls.py` | `license/`, `license/heartbeat/` |
| `api/migrations/0008_license_stationslot.py` | generated |
| `requirements.txt` | `cryptography==50.0.1` added (PyJWT alone only does HMAC — a shared secret, i.e. the worthless scheme) |
### Vendor tooling — `tools/license_tool.py`
```bash
python tools/license_tool.py keygen # once; refuses to overwrite without --force
python tools/license_tool.py sign --customer "Port of Varna" --stations 5 \
--components '{"board":"...","disk":"...","mac":"...","guid":"..."}'
```
`sign` refuses fewer than 3 components, since such a licence could never validate on the customer machine.
### Frontend
| File | Contents |
|---|---|
| `contexts/LicenseContext.jsx` | 30s heartbeat, derives `station_key` from the bridge's `/status` |
| `components/License/LicenseManager.jsx` + `.css` | Admin screen: state, fingerprint with copy buttons, paste box, connected-station table. 🔑 in header |
| `components/Main.jsx` | Measure gated on `licenseAllowed`, `station_key` sent with both measure calls, grace banner |
| `App.jsx` | `LicenseProvider`, `/license` route (admin only) |
Design note: `LicenseContext` **assumes allowed until told otherwise**, so a slow first heartbeat never blocks a working station. Non-403 failures (server down) leave the previous state alone — a licence check must not stop a working weighbridge.
---
## Security: the private key
**`tools/license_private.key`** — gitignored via `*license*.key`, generated 2026-09-17. Verified not tracked.
- **If it leaks**, every licence ever issued becomes forgeable
- **It cannot be rotated** without reissuing to every customer, since the public key is compiled into shipped binaries
- **If it is lost**, no new licences can ever be signed for existing installs
Back it up outside the repo. Never ship it. The public key in `api/licensing.py` is safe to commit and must be correct in the build before shipping.
---
## Verification (all passing)
### Crypto — 6 checks
1. Genuine licence verifies; customer and station count read back correctly
2. **Tampering rejected** — editing `"stations":5` to `"stations":500` in a real signed blob breaks the signature. *This is the property the entire scheme rests on.*
3. Licence for a different machine rejected (1 of 4 match, 3 required)
4. **3-of-4 tolerance works** — one component changed (simulated disk replacement) still validates
5. Empty, garbage and malformed blobs rejected cleanly with distinct messages
6. Expired licence rejected
### End to end — 10 checks
1. No licence → grace period, 14 days, still usable
2. Install a 5-station licence → customer and count correct
3. Five stations connect → `used=5 licensed=5`
4. **Sixth declined** → 403, "Licence limit reached (5 stations)"
5. The five keep working (refresh succeeds)
6. **Reconnect at the limit reclaims its own slot** → 200, not locked out
7. Offline station frees its slot after timeout → sixth then admitted
8. `measure` with an unregistered station key → 403
9. `measure` from a licensed station → 200
10. Grace expiry (backdated 15 days) → limit drops to 1
Frontend builds clean; no warnings in any licensing file.
---
## Operating notes
**Currently in grace mode** — no licence installed, so everything works and the banner shows. Install one from the 🔑 screen to test the licensed path.
**To issue a licence:** customer opens 🔑 → copies the component JSON from "Full details" → sends it → run `license_tool.py sign` → customer pastes the result into the install box.
The fingerprint shown large (e.g. `C261-A181-3D8D-AC94`) is for reading over the phone to confirm identity. The component JSON underneath is what `sign` actually needs. Both are one-way hashes, so they carry no identifying hardware detail.
---
## Out of scope
- **Expiry / subscriptions** — perpetual only; the payload field exists so this can be added without reissuing the format
- **Licensing individual bridges** — rejected as N× the administration
- **Obfuscation / anti-tamper** — see the threat model
- **Named Station entities** — slots are identified by bridge hash; a Station model was deliberately skipped
@@ -0,0 +1,163 @@
# Main page: 5-group weighing row + form restyle
## Context
The weighing detail panel doesn't match the layout the user wants (reference
screenshot: a single row of five groups — CURRENT READING | TARE | GROSS | NET |
actions). Three things differ today:
1. **Current Reading is a separate full-width block** (`.reading-display`) sitting
*above* the card row, not part of it.
2. **There is a redundant "Scale" card** duplicating the same live weight, which
exists only to host the Measure / Manual entry buttons. The user's call:
these two are the same thing — **merge into one card**.
3. **Tare and Gross are all-or-nothing.** Today a lone first reading renders as a
single neutral "First weighing" card, and Tare/Gross only appear once both
exist. The user wants the five groups always visible, with the first reading
shown plainly in its slot.
Result: a stable five-column row that never changes its column count.
### The one behaviour being deliberately traded away
[Main.jsx:604-605](frontend/src/components/Main.jsx#L604-L605) documents why the
merged card exists: the backend parks a lone reading in `tare`, but a **lighter
second reading moves it to `gross`**. So a single reading labelled "TARE" can
later jump to the GROSS box. The user chose "show it plainly" — accepting that
jump. `firstWeighing` (Main.jsx:290-309) becomes dead and is removed.
## Approach
### 1. Merge Current Reading + Scale into one card
Delete the standalone `.reading-display` block (Main.jsx:581-600) and the Scale
card (647-677). One card takes their place as the row's first column, keeping the
**richer** of the two behaviours — the reading-display value logic, which the
Scale card lacked (disconnected state, `readingError` line):
```jsx
<div className="weight-card weight-card--reading">
<div className="weight-card-header">Current Reading</div>
<div className={`weight-card-value weight-card-value--reading ${…same ternary as today's reading-value…}`}>
{currentWeight !== null && !isNaN(currentWeight)
? `${currentWeight} kg`
: (isConnected ? 'Waiting...' : 'Disconnected')}
</div>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>{readingError}</div>
)}
{canMeasure && !isFinalized && (<>
<button className="weight-set-btn" onClick={handleMeasure}
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
title={licenseAllowed ? undefined : licenseDetail}>Measure</button>
{!licenseAllowed && <div className="weight-license-block">{licenseDetail}</div>}
{currentUser?.can_manually_measure && (
<button className="weight-manual-btn" onClick={…}>Manual entry</button>
)}
</>)}
</div>
```
Keep the `.reading-value--balanced/--unbalanced` class names on the value so the
existing colour rules (Main.css:547-553) and cockpit glow keep applying
unchanged. The `--reading` modifier carries the larger font that
`.reading-value` had.
### 2. Tare / Gross / Net always render
Replace the ternary at Main.jsx:606-644 with three unconditional cards. Each
shows `—` and no meta when its value is null:
- Tare — `selectedVehicle.tare`, `tare_date`, `tare_user_name`, `tare_manual`
- Gross — same shape with `gross_*`
- Net — `netWeight` (null until both exist)
Add modifier classes `weight-card--tare`, `--gross` (Net already has `--net`).
**This is a prerequisite, not polish:** Main.css:576-578 states the cockpit rules
use `:nth-child` precisely because these cards have no distinguishing class, and
the column count is about to change. Those `:nth-child` selectors must be
rewritten to the modifiers in the same pass or cockpit chrome lands on the wrong
cards.
Net keeps Print Notice + the Design Notice split button inside it (unchanged
markup, including the dropdown menu and backdrop). Gate them on `netWeight !==
null` so an empty Net card shows no dead buttons.
### 3. Row becomes a grid
`.weighing-section` moves from `flex` + `flex-wrap` to a 5-column grid, following
the ratio proven in the concept file
([ScalesAppCyberpunk.css:94](frontend/src/components/concepts/ScalesAppCyberpunk.css#L94)):
```css
.weighing-section {
display: grid;
grid-template-columns: 1.3fr 1fr 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
align-items: stretch;
}
```
Four columns, because Print/Design stay inside Net rather than becoming a
separate action column — the user kept them there. Reading gets the wider track.
Remove `.reading-display`'s `margin-bottom: 20px` block along with the element.
Lift `.vehicle-detail { max-width: 700px }` (Main.css:115) — four readouts do not
fit in 700px. The cockpit override at 851-853 that already does this then becomes
redundant and should go.
Responsive, replacing the cockpit-only `@media (max-width:1150px)` rule:
```css
@media (max-width: 1150px) { .weighing-section { grid-template-columns: 1fr 1fr; } }
@media (max-width: 620px) { .weighing-section { grid-template-columns: 1fr; } }
```
These apply to **both** themes now, so drop the `[data-theme="cockpit"]` prefix
from the wrap rules at 858-872.
### 4. Form restyle (styling only, no new capability)
Confirmed scope: the create form only. There is no edit form and none is being
added. In Main.css:
- `.extra-field input/select` — set explicit `background: var(--surface)` and
`color: var(--text)`. They currently inherit UA defaults and survive cockpit
only by accident of `color-scheme: dark`.
- `.extra-fields` — add `@media (max-width: 768px) { grid-template-columns: 1fr }`;
it is a hard `1fr 1fr` at every width today.
- `.vehicle-form-actions` — add `justify-content: flex-end` to match the
`.manual-actions` precedent (Main.css:539+).
## Critical files
- [frontend/src/components/Main.jsx](frontend/src/components/Main.jsx) — lines
581-741 replaced; `firstWeighing` memo (290-309) deleted
- [frontend/src/components/Main.css](frontend/src/components/Main.css) —
`.reading-display` block removed, `.weighing-section` → grid, `max-width` cap
lifted, cockpit `:nth-child` → modifier classes, form rules
- [frontend/src/components/concepts/ScalesAppCyberpunk.css](frontend/src/components/concepts/ScalesAppCyberpunk.css)
— reference only, not modified. Its hardcoded hex and `var(--cyan)`/`var(--green)`
must **not** be copied; translate to the semantic tokens in
[frontend/src/styles/tokens.css](frontend/src/styles/tokens.css) (`--accent`,
`--success`). tokens.css is semantic-by-rule (header comment, lines 5-9).
No backend, serializer or API change. No new dependency.
## Verification
1. `cd frontend && npx vitest run` — 201 tests still pass
2. Browser check at http://localhost:5172, **both themes** via the header toggle,
walking the full weighing lifecycle on one vehicle:
- no vehicle selected → placeholder unchanged
- fresh vehicle, no readings → 4 cards, all `—`, no dead buttons
- after first Measure → value appears in TARE, Gross/Net still `—`
- after second Measure → Tare+Gross+Net populated, Measure button gone,
Print/Design appear in Net
3. Confirm the merged card still shows: `Disconnected` when the bridge is down,
`Waiting...` when connected with no reading, and the balanced/unbalanced
colour on a live value
4. Widths 1400 / 1100 / 600px — grid collapses 4 → 2 → 1 with no overflow
5. Cockpit chrome (clipped corner, lit top edge, Net glow) lands on the right
cards after the `:nth-child` → modifier rewrite
6. Create form: 2-col above 768px, 1-col below; Cancel/Create right-aligned;
inputs readable in cockpit
+224
View File
@@ -0,0 +1,224 @@
# Multi-Protocol Scale Support
## Context
The port runs 10 scales from 6 manufacturers (Mettler-Toledo, Esit, Elikom, Libra, 3 no-name), each speaking a different serial protocol. ScalesApp currently assumes one format and parses it with a regex in `Main.jsx`.
The hard constraint is **operational, not technical**: installation and upgrades must need minimal involvement from the developer. So the design question is not "how do we parse 6 formats" but **"where does protocol knowledge live so that adding a scale never means visiting 10 PCs with a new binary."**
**Answer: the bridge knows nothing about protocols.** It is pure transport — open the port, optionally send a poll string on an interval, stream raw lines. Protocol definitions live in Django, are cached by the frontend, and parsing happens in the browser where the weight is displayed. A new scale type is a web-UI change; the bridge binary never changes for it.
**Decisions made with the user:**
- Bridge = transport only. It learns its poll string at runtime from the UI, so a scale with a different command (`%p`, `SI`, `#w`) needs no bridge update.
- Protocol definitions live in Django, cached in the frontend (same pattern as `NomenclatureDataContext`).
- Live weight keeps going **bridge → UI directly** on localhost. Django stays out of the hot path, preserving the independence the project CLAUDE.md calls out.
- Admin gets a COM monitor (raw text + hex) and a fixed-column protocol builder. Exotic protocols stay code.
- Four protocols seeded: Simulator (`ST`/`US`), fixed-column `%p`, Toledo Continuous, Mettler-Toledo SICS.
---
## Why the browser, not the bridge, parses
The bridge is a PyInstaller binary on 10 station PCs. Anything it knows is expensive to change. The frontend is served from one place and updates on reload.
Polling is the exception that must stay in the bridge: a browser cannot be the heartbeat. Background tabs get timer-throttled by Chrome, a closed tab stops the scale, two open tabs double the rate. So the **bridge owns the poll loop, the UI owns the poll string.**
---
## Bridge changes (`serial_bridge/app.py`)
Note `serial_reader.py` and `backend_client.py` in that folder are dead code — nothing imports them. Leave them alone; all live code is in `app.py`.
### 1. Runtime poll configuration
```
POST /poll {"command": "%p", "interval": 0.2} -> bridge stores and runs the loop
POST /poll {"command": ""} -> continuous mode, no polling
GET /poll -> current setting
```
The UI pushes this on connect, from the station's protocol definition. The bridge writes the bytes verbatim — `%p` bare, **no terminator** (confirmed against the original Delphi, where `TComPort.WriteStr('%p')` appends nothing).
**Persist the last setting to a local file** (`poll_config.json` beside the executable) and restore it on startup. Otherwise a bridge restarted with no browser open sits idle in a confusing dead state.
### 2. One-off command endpoint — for the admin monitor
```
POST /send {"command": "%p"}
```
Writes once, does not change the poll loop. This is the probe tool for identifying an unknown scale.
### 3. Raw bytes alongside text
`read_data()` currently does `.decode('utf-8', errors='ignore')`, which **silently corrupts binary frames** — Toledo Continuous starts with `STX` (0x02) and packs status into bit fields. Add a `hex` field to every SSE payload:
```python
{'port': ..., 'data': '<utf-8, lossy>', 'hex': '02 20 30 ...', 'timestamp': ...}
```
The UI parses text protocols from `data` and binary ones from `hex`. Cheap now, painful to retrofit once something depends on `data`.
### 4. Polling loop
Extend `read_serial_data()`: when a poll command is set, write it, wait briefly, then read. Keep the existing buffer-drain behaviour for continuous mode (it discards stale lines and keeps the newest — added earlier today; essential for streaming scales, harmless for polled ones).
### 5. `.env` shrinks to machine-local hardware facts
```
COM_PORT=COM3
BAUD_RATE=9600
TIMEOUT=1
PORT=5000
LOG_LEVEL=INFO
```
No protocol, no poll command, no `STATION_ID`. COM port and baud must stay local — Django cannot know this PC's wiring.
### 6. Version reporting
Bridge reports its version in `/status`. Surfaced in the UI so stale stations are visible at a glance rather than discovered when one misbehaves.
---
## Backend: protocol definitions
### Model — `api/models.py`
```python
class ScaleProtocol(models.Model):
name = models.CharField(max_length=100, unique=True)
kind = models.CharField(max_length=20, choices=[...]) # fixed_column | regex | toledo_continuous | sics
poll_command = models.CharField(max_length=20, blank=True) # '' = continuous
poll_interval = models.FloatField(default=0.2)
config = models.JSONField(default=dict) # kind-specific parameters
is_builtin = models.BooleanField(default=False) # seeded; not user-deletable
```
`config` for `fixed_column` (the case the builder targets, and what covers the no-names):
```json
{"weight_start": 4, "weight_length": 8,
"stable_pos": 21, "stable_char": " ", "stable_means_balanced": true,
"decimals": 0}
```
**Offsets stored 0-based**, converted for display in the builder. The `%p` scale's "weight from char 5, length 8; stability last char" is 1-based Delphi — so `weight_start: 4`, `stable_pos: 21`.
Seed four via data migration: Simulator, `%p` fixed-column, Toledo Continuous, SICS. Marked `is_builtin`.
ViewSet + route `scale-protocols` in `api/urls.py`, following the existing router convention.
---
## Frontend
### 1. Protocol cache — `contexts/ScaleProtocolContext.jsx`
Fetch `/api/scale-protocols/` on mount, cache, subscribe to SSE updates. Mirrors `NomenclatureDataContext`, which already does exactly this; reuse its shape rather than inventing a new one.
### 2. Parsers — `services/scaleProtocols.js`
One function per kind, all returning the same shape:
```js
parse(reading, protocol) -> { weight: number|null, balanced: boolean, error: string|null }
```
- **`fixed_column`** — slice by offsets. Must **not** trim before reading `stable_pos`: in the `%p` format a *space* means balanced, so trailing-whitespace stripping would invert the signal and make every reading look unstable.
- **`toledo_continuous`** — parse from `hex`. STX + SWA/SWB/SWC + 6 weight + 6 tare digits + CR. Stability is the **motion bit in SWB** (set = moving), decimal position from SWA.
- **`sics`** — `S S 16480 kg` → stable; `S D` → dynamic; `S I` / `S +` / `S -` → error states.
- **`simulator`** — trailing `ST`/`US`, the current behaviour.
**Return an explicit error rather than a plausible weight** when a frame doesn't match. Toledo and SICS are written from documented structure without hardware to test against, so they must fail loudly — a silently wrong weight on a weighbridge is worse than no weight.
### 3. `Main.jsx` — replace the inline regex
The current `currentWeight` / `isBalanced` memos parse the reading directly. Replace with a call to the selected protocol's parser. Everything downstream (the red/green colouring, the Measure button) already consumes `{weight, balanced}` and is unaffected.
The UI POSTs `/poll` to the bridge on connect with the station's protocol command and interval.
### 4. Admin: COM monitor + protocol builder
New page, gated on `is_admin`:
- **Live raw view** — each incoming line as text and hex, scrolling, pause/capture to file. Hex matters: it's the only way to see `STX` or trailing spaces.
- **Send command** — free-text box posting `/send`, for probing an unknown scale.
- **Fixed-column builder** — form for weight start/length, stability position and character; **applied live against the captured lines** so the admin sees the parsed result before saving. This is what makes it usable without the developer.
---
## Which station uses which protocol
Deployment is flexible (one central Django at the port; single-machine elsewhere), so the browser identifies its station implicitly: **it talks to whichever bridge is on localhost:5000.** No cookie, no per-browser state — cookies were rejected because they are per-browser, wiped by clearing site data, and unreadable by the bridge.
The protocol selection for a station is held in Django and chosen in the UI. With one scale per PC this is a single selection; it does not require a full Station entity yet.
---
## Edge cases
| Case | Behaviour |
|---|---|
| Frame doesn't match the protocol | Parser returns `error`; UI shows unparseable rather than a wrong number |
| Binary frame through `data` | Use `hex`; `data` is lossy by design |
| `%p` reply shorter than 22 chars | Guard offsets; return error, never slice past the end |
| Bridge restarted, no browser open | Restores poll config from its local file and keeps polling |
| Two tabs open | Both POST `/poll` with the same values — idempotent, loop stays single |
| Django down | Live weight unaffected; UI uses cached protocol definitions |
---
## Verification
1. **Simulator unchanged** — existing test writer keeps working end to end; weight displays and colours as today. This is the regression guard for the whole refactor.
2. **Fixed-column against a synthetic `%p`** — extend the test writer to emit a 22-char frame; confirm weight and the space-means-balanced rule, including that a trailing space survives.
3. **Toledo/SICS** — unit-test the parsers against hand-built frames. Mark clearly as **unvalidated against real hardware** until a real scale confirms them.
4. **Poll loop** — POST `/poll`, confirm the bridge writes on interval; empty command stops it; setting survives a bridge restart.
5. **Hex field** — verify a frame containing 0x02 arrives intact in `hex` while `data` is lossy.
6. **Builder** — capture lines from the simulator, build a fixed-column protocol in the UI, confirm the live preview parses correctly before save.
7. **Offsets** — the `%p` 1-based → 0-based conversion, explicitly tested. One position off silently clips a digit.
---
## Files
**Bridge** — `serial_bridge/app.py` (poll endpoints, `/send`, hex field, poll loop, version), `serial_bridge/.env`
**Backend** — `api/models.py`, `api/serializers.py`, `api/views.py`, `api/urls.py`, migration + seed data migration
**Frontend** — `contexts/ScaleProtocolContext.jsx`, `services/scaleProtocols.js`, `components/Main.jsx`, new admin monitor/builder component
**Not touched** — `serial_reader.py`, `backend_client.py` (dead code)
---
## Deliberately out of scope
- **Station entity** — one scale per PC makes it unnecessary now; add it if a PC ever drives two scales.
- **Bridge self-update** — version reporting only. Real update machinery is speculative until you see how often bridges actually change.
- **Regex/delimited builder kinds** — the model supports the `kind` field, but only fixed-column gets builder UI. Add others if a real scale needs one.
---
## Implementation notes (built 2026-09-17)
All three tiers built and verified. Deviations and details worth recording:
**Protocol selection is per-browser (`localStorage`), not a Station model.** With one scale per PC the browser talks to its own bridge on localhost, so a full Station entity was not needed. If a PC ever drives two scales, that changes.
**Bridge version 1.1.0** — reported in `/status` and shown in the monitor toolbar.
**`poll_config.json` sits beside the bridge executable** and is gitignored along with `serial_bridge.log`. Both are per-machine runtime state.
**Fallback protocol**: if none is chosen, the frontend uses the Simulator definition so a fresh install shows a weight rather than an error.
### Verified
- **27 parser unit tests pass** (scratchpad `test_protocols.mjs`), covering all four kinds plus error paths. Notably: space-means-balanced for `%p`, inverted stability, `decimals` scaling, Toledo motion bit and sign bit, SICS `I`/`+`/`-` error states, and `"16480 kg BEST"` correctly NOT read as stable.
- **Backend API**: seeded 4 protocols; custom create/delete; built-ins protected from deletion; invalid offsets and non-positive poll intervals rejected.
- **Bridge endpoints**: `/poll` get/set, persistence across restart, empty command = continuous, bad intervals rejected, `/send` 503 when disconnected, `/status` reports version and poll state.
- **Byte handling**: a binary Toledo frame survives byte-exact in `hex` (STX intact); a 22-char `%p` line keeps its significant trailing space while CRLF is stripped; the backlog drain still keeps only the newest line.
### Still unvalidated
**Toledo Continuous and SICS have never seen real hardware.** Both are written from documented structure. They return explicit errors rather than guesses on malformed frames, and the monitor shows the raw hex needed to correct them. Expect the first real Toledo to need a tweak — most likely the SWA decimal-point table or a SWB bit position.
@@ -0,0 +1,115 @@
# Plan: Drag-to-reorder nomenclatures, reflected in vehicle "Additional Data"
## User Request
> in nomenclatures overlay, make the rows in the list draggable, so they can be rearranged. then in vehicle, show the fields in this order
Clarified with screenshots: the target is the **left-hand list in the Nomenclatures overlay**
(Агент, Товар, Товародател, № Контейнер, Корабособственик). The client wants to be able to move
e.g. "Агент" to the bottom of the list, and the vehicle screen's "Additional Data" dropdowns
should then appear in that same order.
## Plan
### Context
The backend already supports this fully: `Nomenclature.sort_order` exists
(`backend/nomenclatures/models.py:29`), `Meta.ordering = ["sort_order", "code"]`
(`models.py:32`), the viewset orders by it (`backend/api/views.py:260`), the serializer
exposes it as writable (`backend/api/serializers.py:264`), and `Main.jsx:31` already sorts
`vehicleNomenclatures` by it. **No backend change and no migration are needed.**
The reason the order looks fixed today is that every existing row has `sort_order = 0` — the
model default — so the sort is a no-op and rows fall back to `code` order. Assigning real
values on the first drag fixes that permanently.
Decisions taken with the user:
- Persist by PATCHing each moved row (frontend-only; no new endpoint).
- Save immediately on drop — no Save-button staging.
- The whole row is draggable (no separate grip handle).
### Scope
Two files change. Both are frontend.
#### 1. `frontend/src/components/NomenclatureManager/NomenclatureManager.jsx`
Add native HTML5 drag-and-drop to the left list (lines 189–217). No library — `package.json`
has no DnD dependency and adding one for a five-row list is not warranted.
**State:** two new pieces of local state — `dragIndex` (index of the row being dragged,
`null` when idle) and `dragOverIndex` (index the row would drop into, for the drop-line
indicator). Plus reuse of the existing `error` state for failure messages.
**Row markup** (`.nm-list-item`, line 191): add
- `draggable={canEdit}` — reordering is an edit, so gate it on the same `canEdit`
(`currentUser?.can_manage_entities`) that already gates the edit/add/delete buttons.
- `onDragStart` → record `dragIndex`; call `e.dataTransfer.setData('text/plain', String(idx))`
(Firefox refuses to start a drag without data set) and `e.dataTransfer.effectAllowed = 'move'`.
- `onDragOver` → `e.preventDefault()` (required or no drop event fires) and set `dragOverIndex`.
- `onDragEnd` → clear both indices (fires even on a cancelled drag, so it is the reliable reset).
- `onDrop` → `e.preventDefault()`, then run the reorder below.
- `className` gains conditional `nm-list-item--dragging` / `nm-list-item--drag-over` modifiers.
Change the `.map(nom => ...)` at line 190 to `.map((nom, idx) => ...)` so handlers have the
index. The React `key={nom.id}` at line 192 is already stable and correct — leave it.
**Reorder handler** `handleDrop(targetIdx)`:
1. Bail if `dragIndex === null || dragIndex === targetIdx`.
2. Build the reordered array with a splice-out/splice-in on a copy of `nomenclatures`.
3. Write `sort_order: i` onto every element of the new array, and `setNomenclatures(...)`
immediately — optimistic update, so the list does not visibly snap back while the requests
are in flight.
4. Diff against the previous array and PATCH only rows whose `sort_order` actually changed:
`api.patch('/api/nomenclatures/${id}/', { sort_order: i })`, all in one `Promise.all`.
PATCH (not PUT) matters — `NomenclatureSerializer.update` deletes and recreates the whole
`fields` collection (`backend/api/serializers.py:284-287`), so a PUT without a `fields` key
would wipe the field definitions. A partial update leaves `fields` untouched.
5. On rejection: `setError('Failed to save order')` and `await loadNomenclatures()` to resync
from the server.
Note the first drag will PATCH nearly every row, because they all currently sit at
`sort_order = 0` and the diff sees every position as changed. That is correct and self-healing —
subsequent drags only touch the rows between the source and target.
**One caveat to handle:** each PATCH triggers `sse_broadcast_update('nomenclature', 'update', ...)`
(`backend/api/views.py:268`), and `NomenclatureDataContext.jsx:82-85` responds to any
`nomenclature` event with a full `loadAll()` refetch. For a 5-row reorder that is ~5 events
collapsing into a few refetches of a small payload — acceptable, and it is what makes the
vehicle screen update live without a page reload. No debounce needed at this scale; revisit
only if the list ever grows large.
#### 2. `frontend/src/components/NomenclatureManager/NomenclatureManager.css`
- `.nm-list-item` (line 117) — the existing rule already has `cursor: pointer`; leave it. Add
`user-select: none` so dragging does not paint a text selection across the row.
- `.nm-list-item--dragging` — `opacity: 0.4`, to show which row is in flight.
- `.nm-list-item--drag-over` — a drop indicator in the accent already used by
`.nm-list-item--active` (line 132). Because `.nm-list-item` has a `border-bottom` (line 122),
use an inset `box-shadow: inset 0 2px 0 #667eea` rather than a border so row height does not
shift by 2px during the drag.
### Explicitly not in scope
- `NomenclatureField` rows (the "Fields" table on the right panel) — they have their own
`sort_order` that the save payload currently drops, but the user asked about the left list
only. Flagged to the user as a separate known gap, not fixed here.
- `NomenclatureEntry` ordering — entries have no `sort_order` column at all; that would need a
migration. Not requested.
- `Main.jsx` — **no change required.** Line 31 already sorts by `sort_order`. Left as is.
### Verification
1. Start backend (`cd backend && uvicorn scalesapp.asgi:application --reload`) and frontend
(`cd frontend && npm start`); log in as a user with `can_manage_entities`.
2. Open the Nomenclatures overlay. Drag **Агент** from the top to the bottom of the left list.
Verify: the row follows the cursor with a drop indicator, and lands last on release.
3. Check the Network tab: several `PATCH /api/nomenclatures/{id}/` requests, each with a
`{sort_order: N}` body only, all returning 200.
4. Without reloading, look at the vehicle screen's "Additional Data" section — **АГЕНТ should
now be the last dropdown**, moved there live by the SSE-driven `loadAll()`.
5. Hard-reload the page (F5) and confirm both the overlay list and Additional Data still show
Агент last — this proves it persisted rather than only living in local state.
6. Re-open the overlay, click a nomenclature to edit it, and confirm its **Fields table still
lists its fields** — this is the PUT-vs-PATCH regression guard from step 4 of the handler.
7. Log in as a user without `can_manage_entities` and confirm rows are not draggable.
+185
View File
@@ -0,0 +1,185 @@
# Selector Nomenclature: Conditional Field Visibility
## Context
Cargo arrives from different sources, and each source needs different data. Cargo from a ship needs the ship (and its voyage); cargo from a warehouse needs the warehouse (and its bay). Today every vehicle nomenclature renders unconditionally, so the operator sees all of them at once and has to know which are relevant — and a vehicle can silently carry both a ship and a warehouse.
This adds a **selector**: a nomenclature the admin configures whose value decides which *other* nomenclatures appear beneath it.
```
Source: [ Ship ▼ ] ← selector
Ship: [ Varna Star ▼ ] ← revealed by the "Ship" choice
Voyage: [ V-2291 ▼ ]
Source: [ Warehouse ▼ ]
Warehouse: [ WH-3 ▼ ] ← revealed instead
Bay: [ Bay 7 ▼ ]
```
The admin defines the choices and what each reveals; it then renders as a dropdown among the extra fields like any other nomenclature.
**Decisions made with the user:**
- **Switching clears hidden values.** Changing Source from Ship to Warehouse clears the ship. A vehicle must never carry data contradicting its own source, and nothing invisible is ever saved.
- **A choice may reveal several nomenclatures.** Ship → SHIP + VOYAGE.
- **No nesting.** A selector reveals only `lookup`/`field` nomenclatures, never another selector. One level deep, so no cycle detection and no recursive rendering.
---
## Model
A third `kind` on the existing `Nomenclature` model — it already distinguishes `lookup` from `field`, so `selector` belongs there rather than in a new entity.
```python
SELECTOR = "selector"
KIND_CHOICES = [
(LOOKUP, "Lookup Table"),
(FIELD, "Custom Field"),
(SELECTOR, "Selector"), # value decides which other nomenclatures show
]
```
The choices and what they reveal:
```python
class SelectorOption(models.Model):
"""One choice of a selector, and the nomenclatures it reveals."""
selector = models.ForeignKey(Nomenclature, on_delete=models.CASCADE,
related_name='selector_options')
value = models.CharField(max_length=50) # stored in extra.data, e.g. "ship"
label = models.CharField(max_length=255) # shown to the operator, e.g. "Ship"
reveals = models.ManyToManyField(Nomenclature, blank=True,
related_name='revealed_by')
sort_order = models.IntegerField(default=0)
class Meta:
ordering = ['sort_order', 'id']
unique_together = [('selector', 'value')]
```
`value` is stored in `vehicle.extra.data` (a short string like `"ship"`), not the option's id. It survives an option being deleted and recreated, and reads meaningfully in reports and raw data — which matters because these values end up in printed documents.
**Validation to enforce on save** (`clean()` and the serializer):
- A selector's `reveals` must not contain another selector (the no-nesting rule) — reject rather than silently ignore, or an admin will configure something that never appears.
- A nomenclature revealed by one selector should not be revealed by another; otherwise its visibility is ambiguous when both selectors are set.
Migration: `nomenclatures/0003_selector.py` (latest is `0002_`).
---
## Which fields are visible
One rule, applied in both the frontend and the serializer:
> A nomenclature is **hidden** if it appears in any `SelectorOption.reveals` *and* its selector's current value is not the option that reveals it. Everything else is visible.
So a nomenclature not referenced by any selector always shows, exactly as today. This keeps every existing installation behaving unchanged.
Expose it as a single reusable helper rather than reimplementing the rule per call site — the frontend and backend disagreeing about visibility is the most likely bug in this feature.
---
## Backend
`api/serializers.py` — `validate_vehicle_extra_data()` gains the rule:
- **A hidden nomenclature's value is rejected.** If Source is `warehouse`, a `SHIP` key in the payload is an error. This is what actually guarantees the "never carries contradictory data" property; the frontend clearing values is convenience, the serializer is the rule.
- **A selector's own value** must match one of its `SelectorOption.value`s.
- **Revealed-but-unset is allowed.** Revealing a field does not make it required — `required` stays a separate `NomenclatureField` concern.
`api/views.py` — `_record_extra_combination()` needs no change: it already filters to lookup kinds, and `selector` is not one. But **the selector value should be part of the combination**, otherwise "Ship + Varna Star" and "Warehouse + WH-3" could collapse together. Include `selector` kinds alongside `lookup` in the projection, and update the quick-fill table's columns to match.
`api/serializers.py` — `NomenclatureSerializer` gains nested `selector_options` (value, label, sort_order, and the revealed nomenclature codes) so the frontend gets the whole configuration in the existing single fetch.
---
## Frontend
### Rendering — `Main.jsx`
`vehicleNomenclatures` currently maps over every definition. It gains a filter using the shared visibility helper, driven by the current `extraData`. Because `extraData` is already state, changing the selector re-renders with the new set immediately — no extra machinery.
Order matters: a revealed nomenclature should render directly beneath its selector, not in global `sort_order` position, or the relationship is invisible to the operator.
### Clearing on switch
When a selector's value changes, clear the values of everything its *previous* option revealed:
```jsx
const handleSelectorChange = (selectorCode, newValue) => {
setExtraData(prev => {
const next = { ...prev, [selectorCode]: newValue };
for (const code of codesRevealedBy(selectorCode, prev[selectorCode])) {
delete next[code];
}
return next;
});
};
```
Clear on the **previous** value, not by diffing visible sets — a nomenclature revealed by both the old and new option (if the admin allows overlap) should keep its value rather than being cleared and immediately re-shown empty.
Applies to both `extraData` and `newExtraData`.
### `NomenclatureDropdown.jsx`
A `selector` kind renders a `<select>` of its options — same markup as the `lookup` branch, but the options come from `selector_options` and the value is the option's `value` string rather than an entry id. No manage button: options are configured in the admin screen, not created ad hoc while weighing.
### `NomenclatureManager.jsx`
The `kind` dropdown gains "Selector". When selected, the fields editor is replaced by an options editor: add/remove/reorder rows of `label` + `value` + a multi-select of nomenclatures to reveal.
The reveal list must exclude selectors (no nesting) and anything already revealed by a different selector — filtered in the UI *and* rejected server-side.
---
## Interaction with existing features
**Quick-fill combinations** — combinations now carry the selector value, so applying one sets the source and reveals the right fields in a single click. This is a genuine improvement: the whole point of the feature is repeat trucks, and source is exactly the kind of thing that repeats. Verify the combinations table's dynamic columns handle a selector column.
**Report editor** — a hidden nomenclature has no value in `extra.data`, so `DBTextElement` bindings to it resolve empty. Existing reports that print a field which is now conditionally hidden will show blank rather than breaking. Worth a note to the user rather than code.
---
## Edge cases
| Case | Behaviour |
|---|---|
| Selector has no value yet | Nothing it reveals is shown |
| Option deleted while vehicles reference its value | Stored value stays; selector shows it as unknown rather than silently blank, so the data is not quietly misread |
| Nomenclature revealed by two selectors | Rejected at configuration time |
| Selector reveals a selector | Rejected at configuration time |
| Existing vehicles predating the selector | No selector key in `extra.data`; revealed fields hidden, previously-entered values remain stored but invisible — call this out, since it is the one case where old data becomes unreachable |
| Admin changes reveals after data exists | Existing vehicles keep their stored values; only new edits follow the new configuration |
The fifth row is the one to raise with the user before building: introducing a selector over existing data can hide values that were legitimately entered. A one-off data check before configuring the first selector is wise.
---
## Verification
1. **Migration** applies; existing nomenclatures unaffected and still render.
2. **Configure** a SOURCE selector with Ship → SHIP, VOYAGE and Warehouse → WAREHOUSE, BAY.
3. **Visibility** — selecting Ship shows SHIP and VOYAGE and hides the warehouse pair; selecting Warehouse inverts it.
4. **Clearing** — pick a ship, switch to Warehouse, switch back: the ship is empty, not remembered.
5. **Serializer rejection** — POST a vehicle with `source=warehouse` and a `SHIP` value directly via the API; expect 400. This is the real enforcement.
6. **Nesting rejected** — try to configure a selector revealing another selector; expect a clear error.
7. **Double-reveal rejected** — try to reveal one nomenclature from two selectors.
8. **Quick-fill** — save a Ship combination, apply it to another vehicle: the selector and both revealed fields populate together.
9. **Unconfigured nomenclatures** — one not referenced by any selector still always shows.
10. **Old vehicle** — one created before the selector opens without errors.
---
## Files
**Backend** — `nomenclatures/models.py` (SELECTOR kind, `SelectorOption`), migration, `api/serializers.py` (visibility rule in `validate_vehicle_extra_data`, nested options in `NomenclatureSerializer`), `api/views.py` (include selector in the combination projection)
**Frontend** — `components/Main.jsx` (filter, clear-on-switch), `components/NomenclatureUI/NomenclatureDropdown.jsx` (selector branch), `components/NomenclatureManager/NomenclatureManager.jsx` (options editor), `contexts/NomenclatureDataContext.jsx` (carry `selector_options`), shared visibility helper
---
## Out of scope
- **Nesting** — deliberately excluded
- **Conditions beyond equality** (ranges, multiple values) — a choice reveals a fixed set; anything richer is a rule engine
- **Required-when-revealed** — `required` stays a `NomenclatureField` concern
+176
View File
@@ -0,0 +1,176 @@
# Single "Measure" Button + Manual Measurement
## Context
The weighing screen has two buttons, **Set Tare** and **Set Gross**. Operators regularly press the wrong one. Since the vehicle is on the scale either empty or loaded, the app can decide which is which — the operator shouldn't have to.
This replaces both with a single **Measure** button. The first press records a weighing; the second press records the other and finalizes the document. Assignment is **by weight**, not by order.
A second, related need: sometimes a weight must be entered by hand (scale offline, disputed reading). That must be recorded as manual, with who, when and why.
**Decisions made with the user:**
- Site handles **both directions** — trucks may arrive loaded and leave empty. So order can't decide; **lighter = tare, heavier = gross**, regardless of which was taken first.
- Pressing Measure on an already-finalized vehicle is **blocked** (button disabled), matching today's behaviour. No silent mutation of an issued document.
- **No clear/redo.** Scales expose a "balanced" flag and measuring will later be gated on it. Not in this change.
- Manual measurement is built **now**, extending the existing `tare_manual` / `gross_manual` booleans with user, date and note per measurement.
---
## Why weight-based assignment
`net = gross - tare`, so assigning the lighter reading to tare makes a negative net structurally impossible. Order-based assignment (first = tare) would produce a negative net for every outbound truck — which the user confirmed happens here.
The tradeoff to be aware of: for two weighings the rule is unambiguous, but it means **the first reading's role is not final until the second arrives**. A truck weighed at 15000 shows as tare; if the next weighing is 8000, the 15000 becomes gross and the 8000 becomes tare. The UI must not present the first reading as settled.
**Handling**: while only one weighing exists, display it in a single neutral card labelled **"First weighing"** rather than filling in the Tare card. Once the second arrives, both cards populate with their final roles. This avoids a value visibly jumping between cards, which would look like a bug.
**Equal weights** (`gross == tare`) yield `net = 0`. Physically it means nothing was loaded or unloaded. Treat as valid and let it finalize — rejecting it would strand the vehicle with no way forward, and a zero-net document is a legitimate record of a truck that came and went unchanged.
---
## Backend
### 1. Model — `backend/vehicles/models.py`
Extend the existing unused `tare_manual` / `gross_manual` booleans with companions. Keep them adjacent to the weight group they describe, matching the file's existing layout.
```python
tare_manual = models.BooleanField(default=False)
tare_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare_manual', on_delete=models.SET_NULL)
tare_manual_date = models.DateTimeField(null=True, blank=True)
tare_manual_note = models.CharField(max_length=255, null=True, blank=True)
```
and the `gross_` equivalents (`related_name='user_vehicle_gross_manual'`).
`related_name` must not collide with the existing `user_vehicle_tare` / `user_vehicle_gross`.
Migration: `python manage.py makemigrations vehicles` → `0006_...` (latest is now `0005_extracombination`). All fields nullable with defaults, so no interactive prompt.
### 2. Assignment helper — `backend/api/views.py`
Add beside `_maybe_finalize`:
```python
def _assign_weighing(vehicle, value, user, manual=False, note=None):
"""Record a weighing. Assigns by weight: lighter becomes tare, heavier becomes gross.
First weighing is parked in tare; the second may swap it if it turns out lighter,
since trucks may arrive either loaded (outbound) or empty (inbound).
"""
```
Logic:
- **Neither set** — park in `tare` (`tare_date`, `tare_user`, and the manual fields when manual). Role is provisional until the second weighing.
- **One set (tare only)** — compare against the parked value. If the new reading is heavier or equal, it becomes `gross`. If lighter, it becomes `tare` and the **parked values move to gross** — carrying their own date, user and manual fields with them, so provenance follows the value rather than the slot. Then `_maybe_finalize`.
- **Both set** — raise; the viewset returns 400. Should be unreachable from the UI (button disabled) but the API must not be trusted to be called only by the UI.
The swap must move **all** companion fields together (`*_date`, `*_user`, `*_manual`, `*_manual_user`, `*_manual_date`, `*_manual_note`). Missing one silently misattributes who took a reading — the most likely bug in this change, and invisible until someone audits a document.
### 3. Endpoint — `backend/api/views.py`
One new action on `VehicleViewSet`, replacing `set_tare` / `set_gross`:
```python
@action(detail=True, methods=['post'], url_path='measure')
def measure(self, request, pk=None):
"""Record a weighing. The app assigns tare vs gross by weight."""
```
- `value` required, coerced to `int` — same validation as the current `set_tare`.
- `manual` (bool, default false) and `note` (string, optional) in the body.
- **Permissions**: reject unless `request.user.can_measure`; additionally require `can_manually_measure` when `manual` is true. These flags exist on `User` and are editable in UserManager but are **currently enforced nowhere** — this is the first enforcement. 403 with a clear detail message.
- **Manual requires a note** — reject blank. A manual override without a reason is not worth recording.
- Returns the serialized vehicle and broadcasts `sse_broadcast_update('vehicle', 'updated', ...)`, matching the existing pattern.
**Delete `set_tare` and `set_gross`.** They bypass the assignment rule, so leaving them routed would leave the wrong-value path open — the exact problem being fixed. Nothing else calls them (verified: only `Main.jsx`).
### 4. Serializer — `backend/api/serializers.py`
Add the six new fields to `VehicleSerializer.fields` and to `read_only_fields` — they are set only through the `measure` action, never by direct PATCH.
---
## Frontend
### 5. `Main.jsx` — replace the two handlers
`handleSetTare` and `handleSetGross` (lines 122-140) collapse into:
```jsx
const handleMeasure = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, { value: currentWeight });
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
}
};
```
Note the current handlers swallow the server message (`catch { setError('Failed to set tare') }`). Surface `detail` instead, so permission and already-finalized errors are legible.
Manual entry: a small modal (weight, required note) posting the same endpoint with `manual: true`. Trigger shown only when `currentUser?.can_manually_measure`, using the `useAuth()` pattern from NomenclatureManager.
### 6. `Main.jsx` — weighing section JSX
Replace the two `weight-card` blocks (lines 362-401). Three states:
- **No weighings** — one neutral card, `---`, with the **Measure** button.
- **One weighing** — one card headed **"First weighing"** showing value, timestamp, user, and a `manual` marker if applicable. Measure button still enabled. Deliberately *not* labelled Tare.
- **Both** — Tare and Gross cards as today, each showing its own meta, plus the existing Net card. Measure button disabled.
Button disabled when: no live weight, or both weights set. Gate visibility on `can_measure`.
Reuse the existing `.weight-card`, `.weight-card-header`, `.weight-card-value`, `.weight-card-meta`, `.weight-set-btn` classes — no new CSS beyond a modifier for the manual marker.
---
## Edge cases
| Case | Behaviour |
|---|---|
| Second reading **lighter** than the first | New value becomes tare; parked values move to gross with all their metadata. Net positive. |
| Second reading **equal** | Becomes gross, `net = 0`, finalizes. Valid record. |
| Measure on a **finalized** vehicle | Button disabled; API returns 400 if called directly. |
| User lacks `can_measure` | 403. Button hidden. |
| Manual without `can_manually_measure` | 403. Trigger hidden. |
| Manual with blank note | 400. |
| Two operators measuring the same vehicle at once | Last write wins, as today. Out of scope — flagged, not fixed. |
---
## Verification
Backend on 8002 (`run_server.bat`), frontend on 5172 (`npm start`).
1. **Migration** — `python manage.py showmigrations vehicles` shows `[X] 0006_...`.
2. **Inbound** — new vehicle, measure 8000 → "First weighing" card, not Tare. Measure 15000 → Tare 8000, Gross 15000, Net 7000.
3. **Outbound (the case order-based logic breaks)** — measure 15000 first, then 8000 → Tare 8000, Gross 15000, Net **7000, positive**. Confirm the 15000's original date/user followed it to gross.
4. **Equal** — 10000 twice → Net 0, finalized.
5. **Third press blocked** — button disabled; direct POST returns 400.
6. **Permissions** — user without `can_measure` gets 403 and no button; without `can_manually_measure`, no manual trigger.
7. **Manual** — record manually with a note; confirm `*_manual`, `*_manual_user`, `*_manual_date`, `*_manual_note` in the shell. Blank note → 400.
8. **Provenance after swap** — the shell check that matters:
```
python manage.py shell -c "from vehicles.models import Vehicle; v=Vehicle.objects.get(vehicle_number='X'); print(v.tare, v.tare_date, v.tare_user, v.tare_manual); print(v.gross, v.gross_date, v.gross_user, v.gross_manual)"
```
Each weight must carry the date/user of the press that produced it.
9. **Old endpoints gone** — POST to `set-tare` returns 404.
10. **Build** — `npm run build` compiles with no new warnings.
---
## Files changed
**Backend**
- `backend/vehicles/models.py` — 6 fields on `Vehicle`
- `backend/vehicles/migrations/0006_...py` — generated
- `backend/api/serializers.py` — fields on `VehicleSerializer`, read-only
- `backend/api/views.py` — `_assign_weighing()`, `measure` action, delete `set_tare`/`set_gross`
**Frontend**
- `frontend/src/components/Main.jsx` — `handleMeasure`, manual modal, rewritten weighing section
- `frontend/src/components/Main.css` — manual marker style
+96
View File
@@ -0,0 +1,96 @@
# UI Theming — switchable themes + "cockpit" dark skin
## Context
The current UI is functional but flat and generic. The user wants to try a
different look (reference: a dark "cockpit" dashboard mockup — deep navy
surfaces, cyan/amber accents, glow on the live reading, photo banner) **without
losing the ability to switch back**.
Today there is no theming layer at all: **0 CSS custom properties, ~430
hardcoded colours across 16 CSS files**. So the switch has to be built before
any new skin can exist.
Key finding that reduces scope: `Main.jsx` **already has the card structure**
the mockup shows — `.weighing-section` containing `.weight-card` elements for
Tare / Gross / Scale / Net, each with header, value and meta. No restructuring
of the detail panel is needed; this is a styling job.
## Approach
Token layer + `data-theme` attribute on `<html>`. Two themes:
- `classic` — reproduces today's look exactly (the safe default)
- `cockpit` — the new dark skin
### 1. Token layer
New `frontend/src/styles/tokens.css`, imported once in `index.css`:
```css
:root, :root[data-theme="classic"] {
--bg, --surface, --surface-raised, --border,
--text, --text-muted, --accent, --accent-contrast,
--success, --warning, --danger,
--radius, --shadow-card, --glow-accent
}
:root[data-theme="cockpit"] { /* same names, dark values */ }
```
Token NAMES are semantic (`--surface`), never literal (`--navy`), so a third
theme is one block.
### 2. Convert existing CSS to var()
Mechanical replacement of hardcoded colours. `classic` values are lifted from
the current CSS so the default theme is **visually unchanged** — that is the
acceptance test.
### 3. Theme switch
- `ThemeContext` (mirrors the existing `AuthContext`/`NomenclatureContext`
pattern in `frontend/src/contexts/`)
- Sets `data-theme` on `document.documentElement`
- Persists to `localStorage`, wrapped in try/catch (private mode can throw)
- Toggle control in `Header.jsx`
- **Default: `classic`** — nothing changes for operators unless they opt in
### 4. Cockpit theme + banner
Dark palette, raised card surfaces, cyan accent, glow on the live reading,
monospace weight readouts. Banner strip behind the vehicle title in
`.vehicle-detail` — image asset TBD (CSS gradient placeholder until the user
supplies one). Banner is cockpit-only and must collapse on short viewports;
this may run on a weigh-station monitor.
## Staging
Deliberately two stages so a wrong palette is caught after 3 files, not 16.
**Stage 1** — tokens, ThemeContext, header toggle, and convert:
- `frontend/src/components/Main.css` (68 colours)
- `frontend/src/components/Header.css` (17)
- `frontend/src/components/ScaleMonitor/ScaleMonitor.css` (47)
- `frontend/src/index.css`, `frontend/src/App.css`
→ user reviews both themes on the main weighing screen.
**Stage 2** — remaining 13 files, once the direction is approved:
NomenclatureManager (79), NomenclatureManagementOverlay (54),
LicenseManager (45), ChangePasswordOverlay (22), ExtraCombinationsTable (20),
DataDisplay (19), Login (15), NomenclatureEntryForm (14),
NomenclatureDropdown (14), FormOverlay, ReportPreviewModal.
## Critical files
- `frontend/src/styles/tokens.css` (new)
- `frontend/src/contexts/ThemeContext.jsx` (new)
- `frontend/src/components/Header.jsx` — toggle control
- `frontend/src/App.jsx` — wrap in ThemeProvider
- `frontend/src/components/Main.css` — the main surface
Report editor CSS (`components/ReportEditor/`) is **out of scope**: the A4
designer renders a paper page that must stay white regardless of theme.
## Verification
1. `npx vitest run` — 201 tests still pass
2. Classic theme vs `git stash` screenshot: **visually identical**
3. Toggle switches instantly, no reload; survives refresh (localStorage)
4. Cockpit: check contrast on the live reading, balanced/unbalanced states,
and that the banner collapses on a short viewport
5. Real browser check of both themes on the main weighing screen
+164
View File
@@ -0,0 +1,164 @@
# Auto-activate the Python venv in VS Code terminals
## Context
Opening a terminal in VS Code does not activate the project's Python venv. Two causes:
1. **The venv is not where VS Code looks.** It lives at `backend/venv`, but the Python
extension's auto-discovery only scans for `.venv`/`venv` directly under the opened
workspace folder (`C:\dev_projects\ScalesApp`) — one level above it.
2. **No interpreter is configured.** There is no `.vscode/settings.json` in the project,
and the user-level settings contain no `python.defaultInterpreterPath`. Without a known
interpreter, the extension has nothing to activate.
Separately, none of the `run_*.bat` scripts activate the venv — they call `python` /
`uvicorn` bare, so they only work when a venv happens to already be active.
**Outcome:** a single `.venv` at the project root that VS Code discovers automatically,
plus batch scripts that activate it themselves and work from any shell state.
### Findings that shape the plan
- The existing `backend/venv` (Python 3.11.9, 138 MB) already has the dependencies from
**all three** requirements files installed (Django + uvicorn, Flask + pyserial +
pystray + PyInstaller). One shared root venv is therefore a faithful consolidation,
not a new decision.
- Base interpreter `C:\dev\Python\Python311\python.exe` (3.11.9) is present — verified.
- `.gitignore` **already** ignores `.vscode/` and `venv/`, and has a `.venv` entry.
The user chose to keep VS Code settings local, so **no `.gitignore` change is needed**.
### Decisions made
- Consolidate into one root `.venv` (standard layout, zero-config discovery).
- Version conflicts between requirements files: **highest version wins** —
install order chosen so the result is `python-dotenv==1.0.1` and `requests==2.32.5`.
- Delete `backend/venv` only **after** the new venv is verified working.
---
## Steps
### 1. Create the root venv
```bat
C:\dev\Python\Python311\python.exe -m venv C:\dev_projects\ScalesApp\.venv
```
→ **verify:** `.venv\Scripts\python.exe --version` prints `Python 3.11.9`.
### 2. Install dependencies (order matters)
Install so that the higher pin of each conflicting package is applied last:
```bat
.venv\Scripts\python.exe -m pip install --upgrade pip
.venv\Scripts\python.exe -m pip install -r backend\requirements.txt
.venv\Scripts\python.exe -m pip install -r test_comport_writer\requirements.txt
.venv\Scripts\python.exe -m pip install -r serial_bridge\requirements.txt
```
`serial_bridge` pins `requests==2.32.5` (higher) and `python-dotenv==1.0.0` (lower), so a
final explicit pin is required to land on the intended "highest wins" result:
```bat
.venv\Scripts\python.exe -m pip install "python-dotenv==1.0.1"
```
→ **verify:** `pip freeze` shows `python-dotenv==1.0.1` and `requests==2.32.5`, and
`pip check` reports no broken dependencies.
### 3. Create `.vscode/settings.json` (local only, already gitignored)
New file — `.vscode/settings.json`:
```json
{
"python.defaultInterpreterPath": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
"python.terminal.activateEnvironment": true
}
```
`python.terminal.activateEnvironment` defaults to `true`, but is stated explicitly here so
the intent is visible and survives a future user-settings change.
→ **verify:** reload the VS Code window, open a fresh terminal, and confirm the prompt is
prefixed with `(.venv)`.
### 4. Make the batch scripts self-contained
Add a `call` to the venv activation script so each one works regardless of shell state.
- **`run_server.bat`** — currently `cd .\backend\` then `uvicorn ...` (also note: the file
has no trailing newline). Activate from the root *before* the `cd`:
```bat
@echo off
call "%~dp0.venv\Scripts\activate.bat"
cd /d "%~dp0backend"
uvicorn scalesapp.asgi:application --host 127.0.0.1 --port 8000 --reload
```
- **`run_comport_reader.bat`** — currently `python .\serial_bridge\app.py`:
```bat
@echo off
call "%~dp0.venv\Scripts\activate.bat"
python "%~dp0serial_bridge\app.py"
```
- **`run_test.bat`** — currently `python .\test_comport_writer\test_writer.py`:
```bat
@echo off
call "%~dp0.venv\Scripts\activate.bat"
python "%~dp0test_comport_writer\test_writer.py"
```
`%~dp0` (the directory of the batch file itself, with trailing backslash) makes each
script independent of the caller's working directory. `stop.bat` needs no change — it only
calls `taskkill`.
### 5. Remove the old venv — only after step 6 passes
```bat
rmdir /s /q C:\dev_projects\ScalesApp\backend\venv
```
Reclaims 138 MB and prevents two interpreters drifting apart.
---
## Verification
Run in order; do not proceed to the deletion in step 5 until all of these pass.
1. **Interpreter** — `.venv\Scripts\python.exe --version` → `Python 3.11.9`.
2. **Dependencies** — `.venv\Scripts\python.exe -m pip check` → no issues;
`pip freeze` shows `python-dotenv==1.0.1`, `requests==2.32.5`.
3. **Auto-activation** — reload the VS Code window, open a **new** terminal:
prompt shows `(.venv)`; `where python` resolves to `...\ScalesApp\.venv\Scripts\python.exe`.
4. **Django backend** — `run_server.bat` from a plain `cmd.exe` (no venv pre-activated):
uvicorn starts and serves on `http://127.0.0.1:8000`.
Also confirm `python manage.py check` in `backend/` reports no issues.
5. **Serial bridge** — `run_comport_reader.bat` starts the Flask server on port 5000
without an import error (`pyserial`, `flask`, `pystray` all resolve).
6. **Test writer** — `run_test.bat` launches without an import error.
If step 4 or 5 fails on a missing package, the old `backend/venv` is still intact — install
the missing package into `.venv` rather than rolling back.
---
## Files touched
| File | Change |
|---|---|
| `.venv/` | **new** — root venv (gitignored) |
| `.vscode/settings.json` | **new** — interpreter path (gitignored, local only) |
| `run_server.bat` | activate venv; use `%~dp0` for path independence |
| `run_comport_reader.bat` | same |
| `run_test.bat` | same |
| `backend/venv/` | **deleted** after verification |
No application code changes. `.gitignore` needs no edit — `.vscode/`, `venv/` and `.venv`
are all already covered.
+2 -2
View File
@@ -21,10 +21,10 @@ CORS_ALLOWED_ORIGINS=http://192.168.24.45:8100,http://localhost:8100
# --- Frontend (baked into the React bundle at build time) --- # --- Frontend (baked into the React bundle at build time) ---
# Address the browser uses to reach the Django API # Address the browser uses to reach the Django API
REACT_APP_API_URL=http://192.168.24.45:8101 VITE_API_URL=http://192.168.24.45:8101
# Address the browser uses to reach the serial bridge SSE stream # Address the browser uses to reach the serial bridge SSE stream
REACT_APP_SERIAL_URL=http://192.168.24.45:8102 VITE_SERIAL_URL=http://192.168.24.45:8102
# --- Serial bridge / test writer --- # --- Serial bridge / test writer ---
BAUD_RATE=9600 BAUD_RATE=9600
+16
View File
@@ -61,3 +61,19 @@ yarn-error.log*
# Claude Code temporary files # Claude Code temporary files
tmpclaude-* tmpclaude-*
nul nul
# Serial bridge runtime state (per-machine, not source)
serial_bridge/poll_config.json
serial_bridge/serial_bridge.log
# Licence signing - the private key must NEVER be committed.
# If it leaks, every licence ever issued becomes forgeable.
tools/license_private.key
*license*.key
# Local config backups
*.env.bak
# Local tooling artifacts
.claude/screenshots/
.playwright-mcp/
+19 -12
View File
@@ -5,8 +5,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## System Overview ## System Overview
ScalesApp is a three-tier weighing scale management system with real-time data synchronization: ScalesApp is a three-tier weighing scale management system with real-time data synchronization:
- **Django Backend** (REST API + Server-Sent Events) on port 8000 - **Django Backend** (REST API + Server-Sent Events) on port 8002
- **React Frontend** (SPA) on port 3000 - **React Frontend** (SPA) on port 5172
- **Serial Bridge** (Flask SSE server) on port 5000 for COM port reading - **Serial Bridge** (Flask SSE server) on port 5000 for COM port reading
## Development Commands ## Development Commands
@@ -25,8 +25,8 @@ python manage.py migrate
python manage.py createsuperuser python manage.py createsuperuser
# Run server # Run server
python manage.py runserver # Development (WSGI) python manage.py runserver 8002 # Development (WSGI)
uvicorn scalesapp.asgi:application --reload # Development (ASGI - for SSE) uvicorn scalesapp.asgi:application --reload --port 8002 # Development (ASGI - for SSE)
# Testing (when implemented) # Testing (when implemented)
python manage.py test python manage.py test
@@ -38,11 +38,16 @@ python manage.py test api.tests.test_models # Single test file
```bash ```bash
cd frontend cd frontend
npm install npm install
npm start # Development server npm run dev # Vite dev server on port 5172 (set in vite.config.mjs)
npm run build # Production build npm start # Alias for `npm run dev`
npm test # Run tests npm run build # Production build (outputs to frontend/build/)
npm run preview # Serve the production build locally
npm test # Run tests (Vitest)
``` ```
Build tooling is **Vite** (dev server, build) and **Vitest** (tests); there is
no `react-scripts`. The dev-server port lives in `vite.config.mjs`, not `.env`.
### Serial Bridge ### Serial Bridge
```bash ```bash
@@ -253,7 +258,8 @@ Element (base class)
- SSE connection authenticated via query param: `/sse-connect/?token=<jwt>` or Authorization header - SSE connection authenticated via query param: `/sse-connect/?token=<jwt>` or Authorization header
**CORS Configuration:** **CORS Configuration:**
- Allowed origins: http://localhost:3000, http://127.0.0.1:3000, http://localhost:5174 - Allowed origins (default): http://localhost:5172, http://127.0.0.1:5172, http://localhost:3000, http://127.0.0.1:3000, http://localhost:5174, http://127.0.0.1:5174
- Override with the `CORS_ALLOWED_ORIGINS` env var (comma-separated)
- Credentials allowed (for cookies if needed) - Credentials allowed (for cookies if needed)
**User Roles:** **User Roles:**
@@ -316,25 +322,26 @@ DB_PORT=5432
**Frontend:** `frontend/.env` **Frontend:** `frontend/.env`
``` ```
REACT_APP_API_URL=http://localhost:8000 VITE_API_URL=http://localhost:8002
``` ```
(Vite exposes only `VITE_*` vars, as `import.meta.env.VITE_*`. The dev-server
port is set in `vite.config.mjs`, not here.)
**Serial Bridge:** `serial_bridge/.env` **Serial Bridge:** `serial_bridge/.env`
``` ```
COM_PORT=COM1 COM_PORT=COM1
BAUD_RATE=9600 BAUD_RATE=9600
BACKEND_URL=http://localhost:8000
AUTO_CONNECT=True AUTO_CONNECT=True
DEBUG=False DEBUG=False
``` ```
## Running the Full Application ## Running the Full Application
1. Start Django: `cd backend && python manage.py runserver` 1. Start Django: `cd backend && python manage.py runserver 8002`
2. Start Serial Bridge: `cd serial_bridge && python app.py` 2. Start Serial Bridge: `cd serial_bridge && python app.py`
3. Start React: `cd frontend && npm start` 3. Start React: `cd frontend && npm start`
For SSE to work properly in Django, use uvicorn instead: `uvicorn scalesapp.asgi:application --reload` For SSE to work properly in Django, use uvicorn instead: `uvicorn scalesapp.asgi:application --reload --port 8002`
## Common Patterns ## Common Patterns
+3 -5
View File
@@ -20,9 +20,7 @@ ScalesApp/
├── serial_bridge/ # Python serial reader (system tray) ├── serial_bridge/ # Python serial reader (system tray)
│ ├── app.py │ ├── app.py
│ ├── serial_reader.py │ ├── serial_reader.py
│ ├── backend_client.py
│ ├── tray_icon.py │ ├── tray_icon.py
│ ├── config.py
│ ├── requirements.txt │ ├── requirements.txt
│ ├── serial_bridge.spec │ ├── serial_bridge.spec
│ └── .env.example │ └── .env.example
@@ -92,7 +90,7 @@ npm install
#### b. Setup environment variables #### b. Setup environment variables
```bash ```bash
copy .env.example .env copy .env.example .env
# Ensure REACT_APP_API_URL=http://localhost:8000 # Ensure VITE_API_URL=http://localhost:8000
``` ```
#### c. Start development server #### c. Start development server
@@ -234,7 +232,7 @@ DATABASE_URL=sqlite:///db.sqlite3
### Frontend (frontend/.env) ### Frontend (frontend/.env)
```env ```env
REACT_APP_API_URL=http://localhost:8000 VITE_API_URL=http://localhost:8000
``` ```
### Serial Bridge (serial_bridge/.env) ### Serial Bridge (serial_bridge/.env)
@@ -254,7 +252,7 @@ DEBUG=False
- **Database errors**: Run migrations: `python manage.py migrate` - **Database errors**: Run migrations: `python manage.py migrate`
### Frontend Issues ### Frontend Issues
- **Cannot connect to backend**: Ensure Django is running and `REACT_APP_API_URL` is correct - **Cannot connect to backend**: Ensure Django is running and `VITE_API_URL` is correct
- **Port 3000 in use**: Kill process: `netstat -ano | findstr :3000` - **Port 3000 in use**: Kill process: `netstat -ano | findstr :3000`
### Serial Bridge Issues ### Serial Bridge Issues
+8
View File
@@ -0,0 +1,8 @@
run_server
run_comport_reader
run_test
cd frontend
npm start
@@ -0,0 +1,2 @@
- [SSE operation_map fallback](sse-operation-map-fallback.md) — unmapped `sse_broadcast_update` action strings pass through to the frontend verbatim as `operation`.
- [NomenclatureContext SSE dispatch structure](nomenclature-context-sse-dispatch.md) — how `handleSSEUpdate` routes object type to array key and operation to array mutation.
@@ -0,0 +1,33 @@
---
name: nomenclature-context-sse-dispatch
description: How handleSSEUpdate in NomenclatureContext.jsx routes an SSE message's object/operation to state mutations
metadata:
type: project
---
`frontend/src/contexts/NomenclatureContext.jsx`'s `handleSSEUpdate(message)`:
1. Special-cases `object === 'nomenclature' | 'nomenclature_entry' | 'extra_combination'`
and `object === 'invoice' | 'notice'` — these just fan out to `subscribersRef`
callbacks and return early, bypassing the nomenclature-array logic entirely.
2. Otherwise maps `message.object` (the SSE `sseObjectType`, e.g. `'vehicle'`) to a
`nomenclatureKey` (e.g. `'vehicles'`) via `getNomenclatureKeyFromSSE`, which just
looks up `NOMENCLATURES_CONFIG` by `sseObjectType` — this mapping is
operation-agnostic, so it already works correctly for any new operation string
on an existing object type (no changes needed there when adding new operations).
3. Inside `setNomenclatures(prev => {...})`, an `if/else if` chain on `operation`
decides how to mutate `prev[nomenclatureKey]`:
- `'insert'`: skip if `id` already present, else `insertSorted`.
- `'update'`: `updateSorted` (removes old, re-inserts at sorted position, or
falls back to `insertSorted` if the item wasn't present at all).
- Falls through to `return prev` (no-op) for anything unhandled — this was true
even for the backend's own long-standing `'deleted'` action before this
session; there was no delete/removal handling of any kind until Phase B of
`.claude/plans/finalize-history-void.md` added `'archived'` (filter out by id)
and `'voided'` (update-in-place if present, safe no-op if not) as new
`else if` branches alongside `insert`/`update`.
`insertSorted`/`updateSorted` (same file, ~line 62 and ~90) are the two shared
mutation helpers — always spread-based (`[...array]`, `.filter`, `.map`), never
in-place `.push`/`.splice` on the original array. Any new operation case should
reuse these two helpers rather than writing new sort/insert logic.
@@ -0,0 +1,37 @@
---
name: sse-operation-map-fallback
description: sse_broadcast_update's operation_map only maps created/updated/deleted; other action strings pass through unchanged to the frontend's `operation` field
metadata:
type: project
---
`backend/scalesapp/sse.py`'s `sse_broadcast_update(object_name, action, data)` maps
`action` through a small dict:
```python
operation_map = {
'created': 'insert',
'updated': 'update',
'deleted': 'delete'
}
...
'operation': operation_map.get(action, action),
```
Any action string NOT in that dict (e.g. `'archived'`, `'voided'`, added for the
vehicle finalize/void feature) is **not translated** — it arrives client-side in
`message.operation` exactly as the backend passed it, verbatim.
**Why:** confirmed by reading the actual source during Phase B of
`.claude/plans/finalize-history-void.md` (finalize/history/void feature), rather
than assuming the plan's own hedge ("mapped through operation_map, or handled as
raw fallback — check which is simpler") was already resolved. It wasn't resolved
in the plan text; the code settles it.
**How to apply:** when adding a new `handleSSEUpdate` case in
`frontend/src/contexts/NomenclatureContext.jsx` for a new backend action, do NOT
assume the operation string will be renamed/normalized — check `operation_map` in
`backend/scalesapp/sse.py` first, and if the action isn't in the map, write the
frontend case using the exact same string the backend passes as `action`. Do not
add entries to `operation_map` yourself for a frontend-only task — that's a
backend change and out of scope unless explicitly asked.
+305
View File
@@ -0,0 +1,305 @@
"""Offline node-locked licensing.
Customer sites are air-gapped, so there is no activation server. The flow is:
1. This machine reports a fingerprint built from several hardware components
2. The customer sends that string to the vendor
3. The vendor SIGNS it with their Ed25519 private key
4. The customer pastes the result back; this module VERIFIES it
The critical property is that this code can only ever verify, never generate.
The private key never leaves the vendor's machine, so unpacking the application
does not let anyone mint licences. A shared-secret scheme would be worthless.
See .claude/plans/licensing.md for the threat model: this stops casual copying
and gives a clear contractual line. It does not stop a determined attacker, and
is not intended to.
"""
import base64
import hashlib
import json
import platform
import subprocess
from datetime import timedelta
from django.utils import timezone
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
# Vendor's Ed25519 public key (base64 raw, 32 bytes). The matching PRIVATE key
# lives only on the vendor's machine and must never enter this repository.
#
# Replace this placeholder with the real public key before shipping. While it is
# empty, no licence can validate and the system runs in grace mode.
LICENSE_PUBLIC_KEY = 'HAbweykY+P9jdb81BpfiGVXwM2BIgkh9tBjSHq7RN+I='
# How long an unlicensed or newly-invalid install keeps working normally. A
# licence check must never stop a weighbridge without warning, so a hardware
# failure becomes a support call rather than an outage.
GRACE_DAYS = 14
# Station limit once grace has expired.
UNLICENSED_STATIONS = 1
# A slot not refreshed within this window is released. The frontend refreshes
# every 30s, so a station must miss four in a row - a brief hiccup is harmless.
SLOT_TIMEOUT_SECONDS = 120
# Hardware components must match at least this many of four, so replacing a disk
# or a network card does not invalidate an otherwise identical machine.
MIN_COMPONENT_MATCHES = 3
def _run(command):
"""Run a shell command and return stripped stdout, or None on any failure."""
try:
out = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=10
)
if out.returncode != 0:
return None
value = out.stdout.strip()
return value or None
except Exception:
return None
def _hash(value):
if not value:
return None
return hashlib.sha256(str(value).strip().encode('utf-8')).hexdigest()[:16]
def _powershell(script):
"""Run a PowerShell snippet and return its stripped output, or None."""
return _run(
'powershell -NoProfile -NonInteractive -Command "' + script.replace('"', '\\"') + '"'
)
def _windows_components():
"""Hardware identifiers via PowerShell CIM.
Uses PowerShell rather than wmic: wmic is deprecated and has been REMOVED
from recent Windows 11 builds, where it silently yields nothing. Each lookup
falls back to wmic so older machines still work.
"""
parts = {}
board = _powershell('(Get-CimInstance Win32_BaseBoard).SerialNumber')
if not board:
out = _run('wmic baseboard get serialnumber')
if out:
lines = [l.strip() for l in out.splitlines() if l.strip()]
board = lines[1] if len(lines) > 1 else None
if board and board.lower() not in ('none', 'default string', 'to be filled by o.e.m.'):
parts['board'] = board
disk = _powershell(
'(Get-CimInstance Win32_DiskDrive | Select-Object -First 1).SerialNumber'
)
if not disk:
out = _run('wmic diskdrive get serialnumber')
if out:
lines = [l.strip() for l in out.splitlines() if l.strip()]
disk = lines[1] if len(lines) > 1 else None
if disk:
parts['disk'] = disk
mac = _run('getmac /fo csv /nh')
if mac:
first = mac.splitlines()[0].split(',')[0].strip().strip('"')
if first and first != 'N/A':
parts['mac'] = first
guid = _powershell(
r"(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography').MachineGuid"
)
if not guid:
out = _run(r'reg query "HKLM\SOFTWARE\Microsoft\Cryptography" /v MachineGuid')
if out and 'MachineGuid' in out:
guid = out.split()[-1]
if guid:
parts['guid'] = guid
return parts
def _linux_components():
parts = {}
board = _run('cat /sys/class/dmi/id/board_serial 2>/dev/null')
if board:
parts['board'] = board
disk = _run("lsblk -dno SERIAL 2>/dev/null | head -1")
if disk:
parts['disk'] = disk
mac = _run(
"cat /sys/class/net/$(ls /sys/class/net | grep -v lo | head -1)/address 2>/dev/null"
)
if mac:
parts['mac'] = mac
guid = _run('cat /etc/machine-id 2>/dev/null')
if guid:
parts['guid'] = guid
return parts
def machine_components():
"""Hashed hardware identifiers for this machine.
Missing components come back as None rather than raising - a virtual machine
or restricted environment may not expose all of them, and the 3-of-4 rule
tolerates that as long as enough are present.
"""
raw = _windows_components() if platform.system() == 'Windows' else _linux_components()
return {key: _hash(raw.get(key)) for key in ('board', 'disk', 'mac', 'guid')}
def machine_fingerprint():
"""Short human-readable fingerprint, for the customer to send to the vendor.
Read aloud over the phone or copied from the admin screen, so it is grouped
and uppercase with no ambiguous characters beyond hex.
"""
components = machine_components()
present = [v for v in components.values() if v]
if not present:
return 'UNAVAILABLE'
digest = hashlib.sha256('|'.join(sorted(present)).encode('utf-8')).hexdigest()
body = digest[:16].upper()
return '-'.join(body[i:i + 4] for i in range(0, 16, 4))
def count_matching_components(licensed):
"""How many of the licensed component hashes still match this machine."""
current = machine_components()
matches = 0
for key in ('board', 'disk', 'mac', 'guid'):
want = (licensed or {}).get(key)
if want and current.get(key) == want:
matches += 1
return matches
def verify_license(blob):
"""Verify a signed licence blob.
Returns (valid, payload, reason). `payload` is returned even when the
signature is valid but the machine does not match, so callers can report
which machine the licence was actually issued for.
Blob format: base64("<payload json>.<signature>")
"""
if not blob:
return False, None, 'No licence installed'
if not LICENSE_PUBLIC_KEY:
return False, None, 'No vendor public key compiled in'
try:
decoded = base64.b64decode(blob.strip()).decode('utf-8')
payload_json, signature_b64 = decoded.rsplit('.', 1)
payload = json.loads(payload_json)
signature = base64.b64decode(signature_b64)
except Exception:
return False, None, 'Licence is malformed'
try:
public_key = Ed25519PublicKey.from_public_bytes(
base64.b64decode(LICENSE_PUBLIC_KEY)
)
public_key.verify(signature, payload_json.encode('utf-8'))
except InvalidSignature:
return False, None, 'Licence signature is not valid'
except Exception:
return False, None, 'Licence could not be verified'
matches = count_matching_components(payload.get('components'))
if matches < MIN_COMPONENT_MATCHES:
return False, payload, (
f'Licence was issued for a different machine '
f'({matches} of 4 hardware components match, {MIN_COMPONENT_MATCHES} required)'
)
# Perpetual licences have no expiry; the field exists so subscriptions can be
# added later without changing the signed format.
expires = payload.get('expires')
if expires:
try:
if timezone.now() > timezone.datetime.fromisoformat(expires):
return False, payload, f'Licence expired on {expires}'
except Exception:
return False, payload, 'Licence expiry date is malformed'
return True, payload, None
def license_status():
"""Current licence state, including how many stations may connect.
Falls back to a grace period so an unlicensed or newly-invalid install keeps
working while the customer obtains a key.
"""
from .models import License
record = License.objects.first()
blob = record.key_blob if record else ''
valid, payload, reason = verify_license(blob)
if valid:
return {
'valid': True,
'customer': payload.get('customer', ''),
'stations': int(payload.get('stations', 1)),
'reason': None,
'in_grace': False,
'grace_days_left': None,
'fingerprint': machine_fingerprint(),
}
# Invalid or absent - start (or continue) the grace period.
now = timezone.now()
if record is None:
grace_started = None
else:
if record.grace_started is None:
record.grace_started = now
record.save(update_fields=['grace_started'])
grace_started = record.grace_started
if grace_started is None:
# Nothing installed at all: grace runs from first use, tracked by the
# placeholder row the status endpoint creates.
grace_started = now
days_used = (now - grace_started).days
days_left = max(0, GRACE_DAYS - days_used)
in_grace = days_left > 0
licensed_stations = int(payload.get('stations', 1)) if payload else 1
return {
'valid': False,
'customer': payload.get('customer', '') if payload else '',
'stations': licensed_stations if in_grace else UNLICENSED_STATIONS,
'reason': reason,
'in_grace': in_grace,
'grace_days_left': days_left,
'fingerprint': machine_fingerprint(),
}
def effective_station_limit():
return license_status()['stations']
def live_slot_cutoff():
return timezone.now() - timedelta(seconds=SLOT_TIMEOUT_SECONDS)
@@ -0,0 +1,52 @@
# Generated by Django 6.0.1 on 2026-09-17 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0005_grant_admin_permissions"),
]
operations = [
migrations.CreateModel(
name="ScaleProtocol",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, unique=True)),
(
"kind",
models.CharField(
choices=[
("fixed_column", "Fixed column"),
("simulator", "Simulator (ST/US)"),
("toledo_continuous", "Toledo Continuous"),
("sics", "Mettler-Toledo SICS"),
],
default="fixed_column",
max_length=20,
),
),
(
"poll_command",
models.CharField(blank=True, default="", max_length=20),
),
("poll_interval", models.FloatField(default=0.2)),
("config", models.JSONField(blank=True, default=dict)),
("is_builtin", models.BooleanField(default=False)),
("is_active", models.BooleanField(default=True)),
],
options={
"ordering": ["name"],
},
),
]
@@ -0,0 +1,69 @@
from django.db import migrations
# Offsets are 0-based. The %p scale's original Delphi used 1-based Copy(s, 5, 8),
# i.e. the 5th character onward - which is index 4 here.
PROTOCOLS = [
{
'name': 'Simulator (ST/US)',
'kind': 'simulator',
'poll_command': '',
'poll_interval': 0.2,
'config': {},
},
{
'name': 'Fixed column 22-char (%p)',
'kind': 'fixed_column',
'poll_command': '%p',
'poll_interval': 0.2,
'config': {
'weight_start': 4,
'weight_length': 8,
'stable_pos': 21,
'stable_char': ' ',
'stable_means_balanced': True,
'decimals': 0,
},
},
{
'name': 'Toledo Continuous',
'kind': 'toledo_continuous',
'poll_command': '',
'poll_interval': 0.2,
'config': {},
},
{
'name': 'Mettler-Toledo SICS',
'kind': 'sics',
'poll_command': 'SI',
'poll_interval': 0.2,
'config': {},
},
]
def seed(apps, schema_editor):
ScaleProtocol = apps.get_model('api', 'ScaleProtocol')
for entry in PROTOCOLS:
ScaleProtocol.objects.update_or_create(
name=entry['name'],
defaults=dict(entry, is_builtin=True, is_active=True),
)
def unseed(apps, schema_editor):
ScaleProtocol = apps.get_model('api', 'ScaleProtocol')
ScaleProtocol.objects.filter(
name__in=[e['name'] for e in PROTOCOLS], is_builtin=True
).delete()
class Migration(migrations.Migration):
dependencies = [
('api', '0006_scaleprotocol'),
]
operations = [
migrations.RunPython(seed, unseed),
]
@@ -0,0 +1,55 @@
# Generated by Django 6.0.1 on 2026-09-17 16:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0007_seed_scale_protocols"),
]
operations = [
migrations.CreateModel(
name="License",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key_blob", models.TextField(blank=True, default="")),
("installed_at", models.DateTimeField(auto_now=True)),
("grace_started", models.DateTimeField(blank=True, null=True)),
],
),
migrations.CreateModel(
name="StationSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("station_key", models.CharField(max_length=64, unique=True)),
(
"bridge_version",
models.CharField(blank=True, default="", max_length=20),
),
("com_port", models.CharField(blank=True, default="", max_length=20)),
("first_seen", models.DateTimeField(auto_now_add=True)),
("last_seen", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["first_seen"],
},
),
]
@@ -0,0 +1,27 @@
# Generated by Django 6.0.1 on 2026-09-19 12:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0008_license_stationslot"),
]
operations = [
migrations.AddField(
model_name="report",
name="kind",
field=models.CharField(
choices=[("matrix", "Character matrix"), ("a4", "A4 free positioning")],
default="matrix",
max_length=10,
),
),
migrations.AddField(
model_name="report",
name="page_setup",
field=models.JSONField(blank=True, default=dict),
),
]
+85
View File
@@ -44,11 +44,22 @@ class ComPortReading(models.Model):
class Report(models.Model): class Report(models.Model):
"""Model to store report templates for the report editor""" """Model to store report templates for the report editor"""
MATRIX = 'matrix'
A4 = 'a4'
KIND_CHOICES = [
(MATRIX, 'Character matrix'),
(A4, 'A4 free positioning'),
]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX)
page_width = models.IntegerField(default=80) page_width = models.IntegerField(default=80)
page_height = models.IntegerField(default=66) page_height = models.IntegerField(default=66)
api_endpoint = models.CharField(max_length=500, blank=True, default='') api_endpoint = models.CharField(max_length=500, blank=True, default='')
elements = models.JSONField(default=list) elements = models.JSONField(default=list)
# mm page setup for kind='a4': page, orientation, margins{top,right,bottom,left},
# defaultFont, defaultFontSize
page_setup = models.JSONField(default=dict, blank=True)
created_by = models.ForeignKey( created_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='reports' related_name='reports'
@@ -63,6 +74,80 @@ class Report(models.Model):
return self.name return self.name
class ScaleProtocol(models.Model):
"""How to read a particular scale indicator.
Definitions live here rather than in the serial bridge so a new scale type
is a configuration change, not a new binary on every station PC. The bridge
only moves bytes; the frontend parses them using these rules.
"""
FIXED_COLUMN = 'fixed_column'
SIMULATOR = 'simulator'
TOLEDO_CONTINUOUS = 'toledo_continuous'
SICS = 'sics'
KIND_CHOICES = [
(FIXED_COLUMN, 'Fixed column'),
(SIMULATOR, 'Simulator (ST/US)'),
(TOLEDO_CONTINUOUS, 'Toledo Continuous'),
(SICS, 'Mettler-Toledo SICS'),
]
name = models.CharField(max_length=100, unique=True)
kind = models.CharField(max_length=20, choices=KIND_CHOICES, default=FIXED_COLUMN)
# Empty poll_command means the scale streams on its own (continuous mode).
poll_command = models.CharField(max_length=20, blank=True, default='')
poll_interval = models.FloatField(default=0.2)
# Kind-specific parameters. For fixed_column: weight_start, weight_length,
# stable_pos, stable_char, stable_means_balanced, decimals. Offsets are 0-based.
config = models.JSONField(default=dict, blank=True)
is_builtin = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class License(models.Model):
"""The installed licence. Single row - enforced by save()."""
key_blob = models.TextField(blank=True, default='')
installed_at = models.DateTimeField(auto_now=True)
# Grace period start, stored here rather than in a file so it cannot be reset
# by deleting something on disk, and is included in database backups.
grace_started = models.DateTimeField(null=True, blank=True)
def save(self, *args, **kwargs):
if not self.pk and License.objects.exists():
self.pk = License.objects.first().pk
super().save(*args, **kwargs)
def __str__(self):
return 'License' if self.key_blob else 'License (none installed)'
class StationSlot(models.Model):
"""A station that has recently reported in.
Slots are counted against the licence limit. The frontend refreshes every
30s and a slot is released after SLOT_TIMEOUT_SECONDS, so switching off a
station PC frees its slot automatically.
"""
station_key = models.CharField(max_length=64, unique=True)
bridge_version = models.CharField(max_length=20, blank=True, default='')
com_port = models.CharField(max_length=20, blank=True, default='')
first_seen = models.DateTimeField(auto_now_add=True)
last_seen = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['first_seen']
def __str__(self):
return f"{self.station_key} ({self.com_port})"
class DocumentCounter(models.Model): class DocumentCounter(models.Model):
year = models.IntegerField(unique=True) year = models.IntegerField(unique=True)
last_number = models.IntegerField(default=0) last_number = models.IntegerField(default=0)
+193 -17
View File
@@ -1,8 +1,11 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from .models import ComPortReading, User, Report from .models import ComPortReading, User, Report, ScaleProtocol
from vehicles.models import Vehicle, VehicleExtra from vehicles.models import Vehicle, VehicleExtra, ExtraCombination
from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry from nomenclatures.models import (
Nomenclature, NomenclatureField, NomenclatureEntry, SelectorOption,
)
from nomenclatures.visibility import build_reveal_map, is_visible
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
@@ -76,20 +79,22 @@ class ComPortReadingSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'timestamp'] read_only_fields = ['id', 'timestamp']
class VehicleExtraSerializer(serializers.ModelSerializer): def validate_vehicle_extra_data(value, lookup_only=False):
class Meta: """Validate a {nomenclature_code: value} dict against vehicle nomenclatures.
model = VehicleExtra
fields = ['id', 'data']
read_only_fields = ['id']
def validate_data(self, value): lookup_only=True rejects any key whose nomenclature is not kind='lookup'
or kind='selector' (the projection stored as a reusable combination).
"""
if not value: if not value:
return value return value
nomenclatures = { nomenclatures = {
n.code: n for n in n.code: n for n in
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries') Nomenclature.objects.filter(applies_to='vehicle').prefetch_related(
'fields', 'entries', 'selector_options__reveals'
)
} }
reveal_map = build_reveal_map()
errors = {} errors = {}
for key, val in value.items(): for key, val in value.items():
@@ -99,13 +104,29 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
nom = nomenclatures[key] nom = nomenclatures[key]
if nom.kind == Nomenclature.LOOKUP: # A value for a nomenclature the current selector state hides is a
# contradiction: this is what actually enforces the rule, not the UI.
if val is not None and not is_visible(key, value, reveal_map):
errors[key] = (
f"'{key}' is not applicable for the current selector value."
)
continue
if nom.kind == Nomenclature.SELECTOR:
allowed = {o.value for o in nom.selector_options.all()}
if val is not None and val not in allowed:
errors[key] = f"Must be one of {sorted(allowed)}."
elif nom.kind == Nomenclature.LOOKUP:
if not isinstance(val, int): if not isinstance(val, int):
errors[key] = "Must be a nomenclature entry ID (integer)." errors[key] = "Must be a nomenclature entry ID (integer)."
elif not nom.entries.filter(id=val, is_active=True).exists(): elif not nom.entries.filter(id=val, is_active=True).exists():
errors[key] = f"Entry ID {val} not found in '{nom.code}'." errors[key] = f"Entry ID {val} not found in '{nom.code}'."
elif nom.kind == Nomenclature.FIELD: elif nom.kind == Nomenclature.FIELD:
if lookup_only:
errors[key] = "Only lookup and selector nomenclatures are allowed here."
continue
field_def = nom.fields.first() field_def = nom.fields.first()
if field_def: if field_def:
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str): if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
@@ -123,23 +144,40 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
return value return value
class VehicleExtraSerializer(serializers.ModelSerializer):
class Meta:
model = VehicleExtra
fields = ['id', 'data']
read_only_fields = ['id']
def validate_data(self, value):
return validate_vehicle_extra_data(value)
class VehicleSerializer(serializers.ModelSerializer): class VehicleSerializer(serializers.ModelSerializer):
extra = VehicleExtraSerializer(required=False, allow_null=True) extra = VehicleExtraSerializer(required=False, allow_null=True)
tare_user_name = serializers.CharField(source='tare_user.username', read_only=True, default=None) 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) gross_user_name = serializers.CharField(source='gross_user.username', read_only=True, default=None)
net_user_name = serializers.CharField(source='net_user.username', read_only=True, default=None) net_user_name = serializers.CharField(source='net_user.username', read_only=True, default=None)
void_user_name = serializers.CharField(source='void_user.username', read_only=True, default=None)
class Meta: class Meta:
model = Vehicle model = Vehicle
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name',
'tare', 'tare_date', 'tare_user', 'tare_user_name', 'tare', 'tare_date', 'tare_user', 'tare_user_name',
'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note',
'gross', 'gross_date', 'gross_user', 'gross_user_name', 'gross', 'gross_date', 'gross_user', 'gross_user_name',
'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
'net', 'net_date', 'net_user', 'net_user_name', 'net', 'net_date', 'net_user', 'net_user_name',
'doc_number', 'extra'] 'doc_number', 'extra',
'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason']
read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name', read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name',
'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note',
'gross_date', 'gross_user', 'gross_user_name', 'gross_date', 'gross_user', 'gross_user_name',
'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
'net', 'net_date', 'net_user', 'net_user_name', 'net', 'net_date', 'net_user', 'net_user_name',
'doc_number'] 'doc_number',
'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason']
def create(self, validated_data): def create(self, validated_data):
extra_data = validated_data.pop('extra', None) extra_data = validated_data.pop('extra', None)
@@ -173,6 +211,58 @@ class VehicleSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("Vehicle number cannot be empty") raise serializers.ValidationError("Vehicle number cannot be empty")
return value.strip() return value.strip()
def validate(self, attrs):
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs
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)
class ScaleProtocolSerializer(serializers.ModelSerializer):
class Meta:
model = ScaleProtocol
fields = ['id', 'name', 'kind', 'poll_command', 'poll_interval',
'config', 'is_builtin', 'is_active']
read_only_fields = ['id', 'is_builtin']
def validate_poll_interval(self, value):
if value <= 0:
raise serializers.ValidationError("Poll interval must be greater than zero.")
return value
def validate(self, attrs):
kind = attrs.get('kind', getattr(self.instance, 'kind', None))
config = attrs.get('config', getattr(self.instance, 'config', None) or {})
if kind == ScaleProtocol.FIXED_COLUMN:
# A misconfigured offset silently clips a digit off every weight, so
# reject anything unusable rather than storing it.
errors = {}
for key in ('weight_start', 'weight_length', 'stable_pos'):
value = config.get(key)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
errors[key] = "Must be a non-negative integer."
if not isinstance(config.get('weight_length'), bool) and isinstance(config.get('weight_length'), int) and config['weight_length'] < 1:
errors['weight_length'] = "Must be at least 1."
stable_char = config.get('stable_char', ' ')
if not isinstance(stable_char, str) or len(stable_char) != 1:
errors['stable_char'] = "Must be exactly one character."
if errors:
raise serializers.ValidationError({'config': errors})
return attrs
class NomenclatureFieldSerializer(serializers.ModelSerializer): class NomenclatureFieldSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -255,27 +345,53 @@ class NomenclatureEntrySerializer(serializers.ModelSerializer):
return value return value
class SelectorOptionSerializer(serializers.ModelSerializer):
"""One selector choice; `reveals` is a list of nomenclature codes."""
reveals = serializers.SlugRelatedField(
many=True,
slug_field='code',
queryset=Nomenclature.objects.all(),
required=False,
)
class Meta:
model = SelectorOption
fields = ['id', 'value', 'label', 'reveals', 'sort_order']
read_only_fields = ['id']
def validate_value(self, value):
if not value or not value.strip():
raise serializers.ValidationError("Option value cannot be empty")
return value.strip()
class NomenclatureSerializer(serializers.ModelSerializer): class NomenclatureSerializer(serializers.ModelSerializer):
fields = NomenclatureFieldSerializer(many=True, required=False) fields = NomenclatureFieldSerializer(many=True, required=False)
selector_options = SelectorOptionSerializer(many=True, required=False)
entry_count = serializers.IntegerField(source='entries.count', read_only=True) entry_count = serializers.IntegerField(source='entries.count', read_only=True)
class Meta: class Meta:
model = Nomenclature model = Nomenclature
fields = ['id', 'code', 'name', 'applies_to', 'kind', 'display_field', fields = ['id', 'code', 'name', 'applies_to', 'kind', 'display_field',
'sort_order', 'fields', 'entry_count'] 'sort_order', 'fields', 'selector_options', 'entry_count']
read_only_fields = ['id'] read_only_fields = ['id']
def create(self, validated_data): def create(self, validated_data):
fields_data = validated_data.pop('fields', []) fields_data = validated_data.pop('fields', [])
options_data = validated_data.pop('selector_options', [])
nomenclature = Nomenclature.objects.create(**validated_data) nomenclature = Nomenclature.objects.create(**validated_data)
for field_data in fields_data: for field_data in fields_data:
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data) NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
self._write_options(nomenclature, options_data)
return nomenclature return nomenclature
def update(self, instance, validated_data): def update(self, instance, validated_data):
fields_data = validated_data.pop('fields', None) fields_data = validated_data.pop('fields', None)
options_data = validated_data.pop('selector_options', None)
for attr, value in validated_data.items(): for attr, value in validated_data.items():
setattr(instance, attr, value) setattr(instance, attr, value)
@@ -286,8 +402,20 @@ class NomenclatureSerializer(serializers.ModelSerializer):
for field_data in fields_data: for field_data in fields_data:
NomenclatureField.objects.create(nomenclature=instance, **field_data) NomenclatureField.objects.create(nomenclature=instance, **field_data)
if options_data is not None:
instance.selector_options.all().delete()
self._write_options(instance, options_data)
return instance return instance
@staticmethod
def _write_options(nomenclature, options_data):
"""Recreate the selector's options; `reveals` is m2m so it is set after save."""
for option_data in options_data:
reveals = option_data.pop('reveals', [])
option = SelectorOption.objects.create(selector=nomenclature, **option_data)
option.reveals.set(reveals)
def validate_code(self, value): def validate_code(self, value):
if not value or not value.strip(): if not value or not value.strip():
raise serializers.ValidationError("Code cannot be empty") raise serializers.ValidationError("Code cannot be empty")
@@ -304,6 +432,14 @@ class NomenclatureSerializer(serializers.ModelSerializer):
def validate(self, data): def validate(self, data):
kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP)) kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP))
fields_data = data.get('fields', []) fields_data = data.get('fields', [])
options_data = data.get('selector_options')
if kind == Nomenclature.SELECTOR:
self._validate_selector_options(options_data or [])
elif options_data:
raise serializers.ValidationError({
'selector_options': "Only a 'selector' nomenclature can have options."
})
if kind == Nomenclature.FIELD and fields_data and len(fields_data) != 1: if kind == Nomenclature.FIELD and fields_data and len(fields_data) != 1:
raise serializers.ValidationError( raise serializers.ValidationError(
@@ -320,14 +456,49 @@ class NomenclatureSerializer(serializers.ModelSerializer):
return data return data
def _validate_selector_options(self, options_data):
"""No nesting, and no nomenclature revealed by two different selectors."""
errors = []
seen_values = set()
for option in options_data:
value = option.get('value')
if value in seen_values:
errors.append(f"Duplicate option value '{value}'.")
seen_values.add(value)
for revealed in option.get('reveals', []):
if revealed.kind == Nomenclature.SELECTOR:
errors.append(
f"'{revealed.code}' is a selector; selectors cannot reveal "
f"other selectors."
)
continue
other = (
SelectorOption.objects
.filter(reveals=revealed)
.exclude(selector=self.instance)
.select_related('selector')
.first()
)
if other:
errors.append(
f"'{revealed.code}' is already revealed by selector "
f"'{other.selector.code}'."
)
if errors:
raise serializers.ValidationError({'selector_options': errors})
class ReportSerializer(serializers.ModelSerializer): class ReportSerializer(serializers.ModelSerializer):
created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None) created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None)
class Meta: class Meta:
model = Report model = Report
fields = ['id', 'name', 'page_width', 'page_height', 'api_endpoint', fields = ['id', 'name', 'kind', 'page_width', 'page_height', 'api_endpoint',
'elements', 'created_by', 'created_by_name', 'created_at', 'updated_at'] 'elements', 'page_setup', 'created_by', 'created_by_name', 'created_at', 'updated_at']
read_only_fields = ['id', '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): def validate_name(self, value):
@@ -339,3 +510,8 @@ class ReportSerializer(serializers.ModelSerializer):
if not isinstance(value, list): if not isinstance(value, list):
raise serializers.ValidationError("Elements must be a list") raise serializers.ValidationError("Elements must be a list")
return value return value
def validate_page_setup(self, value):
if not isinstance(value, dict):
raise serializers.ValidationError("page_setup must be an object")
return value
View File
+105
View File
@@ -0,0 +1,105 @@
from datetime import timedelta
from unittest import mock
from django.utils import timezone
from rest_framework.test import APITestCase
from api.models import User
from vehicles.models import Vehicle
STATION = {'station_key': 'test-station'}
class OneWeightTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user('op', password='x', can_measure=True)
self.other = User.objects.create_user('first', password='x')
self.client.force_authenticate(self.user)
p = mock.patch('api.views._station_allowed', return_value=True)
p.start()
self.addCleanup(p.stop)
p = mock.patch('api.views.sse_broadcast_update')
self.sse = p.start()
self.addCleanup(p.stop)
def url(self, v):
return f'/api/vehicles/{v.pk}/one-weight/'
def make(self, **kw):
return Vehicle.objects.create(vehicle_number='CB1234', driver_name='D', **kw)
def test_moves_tare_to_gross_and_finalizes(self):
when = timezone.now() - timedelta(hours=1)
v = self.make(tare=1500, tare_date=when, tare_user=self.other, tare_manual=True,
tare_manual_user=self.other, tare_manual_date=when, tare_manual_note='n')
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 200, r.content)
v.refresh_from_db()
self.assertEqual(v.gross, 1500)
self.assertEqual(v.gross_date, when)
self.assertEqual(v.gross_user, self.other)
self.assertTrue(v.gross_manual)
self.assertEqual(v.gross_manual_user, self.other)
self.assertEqual(v.gross_manual_date, when)
self.assertEqual(v.gross_manual_note, 'n')
for f in ('tare', 'tare_date', 'tare_user', 'tare_manual_user',
'tare_manual_date', 'tare_manual_note'):
self.assertIsNone(getattr(v, f), f)
self.assertFalse(v.tare_manual)
self.assertEqual(v.net, 1500)
self.assertEqual(v.net_user, self.user)
self.assertIsNotNone(v.net_date)
self.assertTrue(v.doc_number)
self.assertEqual(r.data['doc_number'], v.doc_number)
self.assertEqual(r.data['net'], 1500)
self.assertIsNone(r.data['tare'])
self.sse.assert_called_once()
self.assertEqual(self.sse.call_args.args[:2], ('vehicle', 'updated'))
def test_gross_already_set(self):
when = timezone.now()
v = self.make(gross=9000, gross_date=when, gross_user=self.other)
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 200, r.content)
v.refresh_from_db()
self.assertEqual((v.gross, v.gross_user, v.gross_date), (9000, self.other, when))
self.assertIsNone(v.tare)
self.assertEqual(v.net, 9000)
self.assertTrue(v.doc_number)
def _assert_400(self, v):
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 400)
self.assertIn('detail', r.data)
self.sse.assert_not_called()
def test_already_finalized(self):
self._assert_400(self.make(gross=5, net=5, doc_number='2026-1'))
def test_void(self):
self._assert_400(self.make(tare=5, is_void=True))
def test_no_weight(self):
self._assert_400(self.make())
def test_both_weights(self):
self._assert_400(self.make(tare=5, gross=10))
def test_permission_denied(self):
v = self.make(tare=5)
self.client.force_authenticate(self.other)
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 403)
v.refresh_from_db()
self.assertEqual(v.tare, 5)
self.assertIsNone(v.doc_number)
def test_station_not_licensed(self):
v = self.make(tare=5)
with mock.patch('api.views._station_allowed', return_value=False):
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 403)
def test_unknown_vehicle_404(self):
r = self.client.post('/api/vehicles/999999/one-weight/', STATION, format='json')
self.assertEqual(r.status_code, 404)
+6
View File
@@ -12,9 +12,15 @@ router.register(r'readings', views.ComPortReadingViewSet, basename='reading')
router.register(r'vehicles', views.VehicleViewSet, basename='vehicle') router.register(r'vehicles', views.VehicleViewSet, basename='vehicle')
router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature') router.register(r'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
router.register(r'nomenclature-entries', views.NomenclatureEntryViewSet, basename='nomenclature-entry') router.register(r'nomenclature-entries', views.NomenclatureEntryViewSet, basename='nomenclature-entry')
router.register(r'extra-combinations', views.ExtraCombinationViewSet, basename='extra-combination')
router.register(r'scale-protocols', views.ScaleProtocolViewSet, basename='scale-protocol')
router.register(r'reports', views.ReportViewSet, basename='report') router.register(r'reports', views.ReportViewSet, basename='report')
urlpatterns = [ urlpatterns = [
# Licensing
path('license/', views.LicenseView.as_view(), name='license'),
path('license/heartbeat/', views.LicenseHeartbeatView.as_view(), name='license-heartbeat'),
# JWT token endpoints # JWT token endpoints
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
+415 -42
View File
@@ -1,17 +1,23 @@
from rest_framework import viewsets, status from rest_framework import viewsets, status, mixins
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from .models import ComPortReading, User, Report, generate_doc_number from .models import (ComPortReading, User, Report, ScaleProtocol, License,
StationSlot, generate_doc_number)
from .serializers import ( from .serializers import (
ComPortReadingSerializer, UserSerializer, UserDetailSerializer, ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer, ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
NomenclatureEntrySerializer, ReportSerializer, NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
ScaleProtocolSerializer,
) )
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from vehicles.models import Vehicle from vehicles.models import Vehicle, ExtraCombination, combination_hash
from nomenclatures.models import Nomenclature, NomenclatureEntry from nomenclatures.models import Nomenclature, NomenclatureEntry
from scalesapp.sse import sse_broadcast_update from scalesapp.sse import sse_broadcast_update
from . import licensing
class IsAdminUser(IsAuthenticated): class IsAdminUser(IsAuthenticated):
@@ -127,6 +133,24 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
serializer.save(source_ip=self.get_client_ip(self.request)) serializer.save(source_ip=self.get_client_ip(self.request))
_WEIGHING_SUFFIXES = ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note')
def _move_weighing(vehicle, src_prefix, dst_prefix):
"""Copy a recorded weighing to the other slot, carrying all its provenance.
The source slot is left untouched (callers overwrite or clear it as needed).
"""
for suffix in _WEIGHING_SUFFIXES:
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
def _clear_weighing(vehicle, prefix):
"""Reset every field of a weighing slot to its empty state."""
for suffix in _WEIGHING_SUFFIXES:
setattr(vehicle, f'{prefix}{suffix}', False if suffix == '_manual' else None)
def _maybe_finalize(vehicle, user): def _maybe_finalize(vehicle, user):
"""If both tare and gross are set and net hasn't been calculated yet, compute net and assign doc_number.""" """If both tare and gross are set and net hasn't been calculated yet, compute net and assign doc_number."""
if vehicle.tare is not None and vehicle.gross is not None and vehicle.net is None: if vehicle.tare is not None and vehicle.gross is not None and vehicle.net is None:
@@ -137,6 +161,82 @@ def _maybe_finalize(vehicle, user):
vehicle.save() vehicle.save()
def _assign_weighing(vehicle, value, user, manual=False, note=None):
"""Record a weighing. Assigns by weight: lighter becomes tare, heavier becomes gross.
The first weighing is parked in tare; the second may push it to gross if the new
reading is lighter, since trucks may arrive either empty (inbound) or loaded (outbound).
"""
now = timezone.now()
def apply(prefix):
setattr(vehicle, prefix, value)
setattr(vehicle, f'{prefix}_date', now)
setattr(vehicle, f'{prefix}_user', user)
setattr(vehicle, f'{prefix}_manual', manual)
setattr(vehicle, f'{prefix}_manual_user', user if manual else None)
setattr(vehicle, f'{prefix}_manual_date', now if manual else None)
setattr(vehicle, f'{prefix}_manual_note', note if manual else None)
def move(src_prefix, dst_prefix):
_move_weighing(vehicle, src_prefix, dst_prefix)
if vehicle.tare is not None and vehicle.gross is not None:
raise ValueError('Vehicle already has both weights.')
if vehicle.tare is None and vehicle.gross is None:
apply('tare')
elif vehicle.gross is None:
# tare holds the first weighing - keep the lighter one as tare
if value >= vehicle.tare:
apply('gross')
else:
move('tare', 'gross')
apply('tare')
else:
# gross set but tare empty (only reachable via direct API use)
if value <= vehicle.gross:
apply('tare')
else:
move('gross', 'tare')
apply('gross')
vehicle.save()
_maybe_finalize(vehicle, user)
def _record_extra_combination(extra_data):
"""Record the lookup/selector projection of a vehicle's extra data as a reusable combination.
The selector value is part of the combination: without it, "Ship + Varna Star"
and "Warehouse + WH-3" would collapse into indistinguishable rows.
"""
if not extra_data:
return
lookup_codes = set(
Nomenclature.objects.filter(
applies_to='vehicle',
kind__in=[Nomenclature.LOOKUP, Nomenclature.SELECTOR],
)
.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)
class VehicleViewSet(viewsets.ModelViewSet): class VehicleViewSet(viewsets.ModelViewSet):
""" """
API endpoint for vehicle management. API endpoint for vehicle management.
@@ -146,15 +246,20 @@ class VehicleViewSet(viewsets.ModelViewSet):
retrieve: Get a specific vehicle by ID retrieve: Get a specific vehicle by ID
update: Full update of vehicle and extra data update: Full update of vehicle and extra data
partial_update: Partial update of vehicle partial_update: Partial update of vehicle
destroy: Delete a vehicle (cascades to VehicleExtra) destroy: Disabled - vehicles are never deleted, see void() instead
by_number: Get vehicle by vehicle_number by_number: Get vehicle by vehicle_number
set_tare: Set tare weight from scale reading measure: Record a weighing (app assigns tare vs gross by weight)
set_gross: Set gross weight from scale reading one_weight: Finalize a vehicle weighed once (weight becomes gross, net = gross)
void: Flag a completed vehicle as voided (corrective reversal, not a delete)
mark_notice_printed: Signal that a completed vehicle's notice was printed/downloaded
""" """
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all() queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
serializer_class = VehicleSerializer serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
filterset_fields = ['vehicle_number'] filterset_fields = {
'vehicle_number': ['exact'],
'doc_number': ['isnull'],
}
search_fields = ['vehicle_number'] search_fields = ['vehicle_number']
ordering = ['vehicle_number'] ordering = ['vehicle_number']
@@ -162,16 +267,25 @@ class VehicleViewSet(viewsets.ModelViewSet):
instance = serializer.save() instance = serializer.save()
data = VehicleSerializer(instance).data data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'created', data) sse_broadcast_update('vehicle', 'created', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_update(self, serializer): def perform_update(self, serializer):
instance = serializer.save() instance = serializer.save()
data = VehicleSerializer(instance).data data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'updated', data) sse_broadcast_update('vehicle', 'updated', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_destroy(self, instance): def destroy(self, request, *args, **kwargs):
data = VehicleSerializer(instance).data # Vehicles are never deleted - a completed weighing is corrected with
instance.delete() # /void/ instead, which keeps the record (and its doc_number) intact
sse_broadcast_update('vehicle', 'deleted', data) # and visible in History. Overriding destroy(), not perform_destroy():
# ModelViewSet.destroy() calls perform_destroy() then always returns
# 204, so neutering only perform_destroy would silently no-op instead
# of telling the caller this action doesn't exist.
return Response(
{'detail': 'Vehicles cannot be deleted. Use /void/ on a completed vehicle instead.'},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@action(detail=False, methods=['get'], url_path='by-number') @action(detail=False, methods=['get'], url_path='by-number')
def by_number(self, request): def by_number(self, request):
@@ -193,10 +307,33 @@ class VehicleViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
@action(detail=True, methods=['post'], url_path='set-tare') @action(detail=True, methods=['post'], url_path='measure')
def set_tare(self, request, pk=None): def measure(self, request, pk=None):
"""Set tare weight from scale reading""" """Record a weighing. The app assigns tare vs gross by weight."""
vehicle = self.get_object() if not request.user.can_measure:
return Response({'detail': 'You do not have permission to measure.'},
status=status.HTTP_403_FORBIDDEN)
# Licence enforcement. The UI also disables the button, but this is the
# actual rule - the button state is only a convenience.
station_key = (request.data.get('station_key') or '').strip()
if not _station_allowed(station_key):
limit = licensing.effective_station_limit()
return Response(
{'detail': f'This station is not licensed to measure '
f'(limit: {limit} stations).'},
status=status.HTTP_403_FORBIDDEN)
manual = bool(request.data.get('manual', False))
if manual and not request.user.can_manually_measure:
return Response({'detail': 'You do not have permission to measure manually.'},
status=status.HTTP_403_FORBIDDEN)
note = (request.data.get('note') or '').strip()
if manual and not note:
return Response({'detail': 'A note is required for a manual measurement.'},
status=status.HTTP_400_BAD_REQUEST)
value = request.data.get('value') value = request.data.get('value')
if value is None: if value is None:
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
@@ -205,38 +342,274 @@ class VehicleViewSet(viewsets.ModelViewSet):
except (ValueError, TypeError): except (ValueError, TypeError):
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST) 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()
_maybe_finalize(vehicle, request.user)
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() 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: try:
value = int(value) _assign_weighing(vehicle, value, request.user, manual=manual, note=note or None)
except (ValueError, TypeError): except ValueError as exc:
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
vehicle.gross = value
vehicle.gross_date = timezone.now()
vehicle.gross_user = request.user
vehicle.save()
_maybe_finalize(vehicle, request.user)
serializer = self.get_serializer(vehicle) serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'updated', serializer.data) sse_broadcast_update('vehicle', 'updated', serializer.data)
return Response(serializer.data) return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='one-weight')
def one_weight(self, request, pk=None):
"""Finalize a vehicle weighed only once (external clients).
The single weight is treated as gross: net = gross, tare stays empty.
"""
if not request.user.can_measure:
return Response({'detail': 'You do not have permission to measure.'},
status=status.HTTP_403_FORBIDDEN)
station_key = (request.data.get('station_key') or '').strip()
if not _station_allowed(station_key):
limit = licensing.effective_station_limit()
return Response(
{'detail': f'This station is not licensed to measure '
f'(limit: {limit} stations).'},
status=status.HTTP_403_FORBIDDEN)
with transaction.atomic():
vehicle = get_object_or_404(self.get_queryset().select_for_update(of=('self',)),
pk=pk)
self.check_object_permissions(request, vehicle)
if vehicle.doc_number:
return Response({'detail': 'This vehicle is already finalized.'},
status=status.HTTP_400_BAD_REQUEST)
if vehicle.is_void:
return Response({'detail': 'A voided vehicle cannot be finalized.'},
status=status.HTTP_400_BAD_REQUEST)
if (vehicle.tare is None) == (vehicle.gross is None):
return Response(
{'detail': 'Exactly one weight must be recorded to finalize with one weight.'},
status=status.HTTP_400_BAD_REQUEST)
if vehicle.tare is not None:
_move_weighing(vehicle, 'tare', 'gross')
_clear_weighing(vehicle, 'tare')
now = timezone.now()
vehicle.net = vehicle.gross
vehicle.net_date = now
vehicle.net_user = request.user
vehicle.doc_number = generate_doc_number(now.year)
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='void')
def void(self, request, pk=None):
"""Flag a completed vehicle as voided. Does not touch doc_number/net/tare/gross."""
if not request.user.can_edit_documents:
return Response({'detail': 'You do not have permission to void a document.'},
status=status.HTTP_403_FORBIDDEN)
vehicle = self.get_object()
if not vehicle.doc_number:
return Response({'detail': 'Only a completed vehicle can be voided.'},
status=status.HTTP_400_BAD_REQUEST)
reason = (request.data.get('reason') or '').strip()
if not reason:
return Response({'detail': 'A reason is required to void a document.'},
status=status.HTTP_400_BAD_REQUEST)
vehicle.is_void = True
vehicle.void_date = timezone.now()
vehicle.void_user = request.user
vehicle.void_reason = reason
vehicle.save()
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'voided', serializer.data)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='mark-notice-printed')
def mark_notice_printed(self, request, pk=None):
"""Signal that the operator has acted on a completed vehicle's notice (print/download).
Makes no DB change - doc_number already marks the vehicle as finalized.
This only broadcasts the SSE event that removes it from the active list.
"""
if not request.user.can_measure:
return Response({'detail': 'You do not have permission to do this.'},
status=status.HTTP_403_FORBIDDEN)
vehicle = self.get_object()
if not vehicle.doc_number:
return Response({'detail': 'Only a completed vehicle can be archived.'},
status=status.HTTP_400_BAD_REQUEST)
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'archived', serializer.data)
return Response(serializer.data)
class ExtraCombinationViewSet(mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet):
"""
API endpoint for previously-used combinations of vehicle extra (lookup) data.
list: Get all combinations, most recently used first
retrieve: Get a specific combination
destroy: Delete a combination the user no longer needs
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)
def _live_slots():
return StationSlot.objects.filter(last_seen__gte=licensing.live_slot_cutoff())
def _station_allowed(station_key):
"""Whether this station may measure.
A station already holding a live slot is always allowed - including when the
licence is full - so a brief disconnection does not lock a working station
out behind its own expiring slot.
"""
if not station_key:
return False
if _live_slots().filter(station_key=station_key).exists():
return True
return _live_slots().count() < licensing.effective_station_limit()
class LicenseView(APIView):
"""Licence status, and installing a new licence key."""
permission_classes = [IsAuthenticated]
def get(self, request):
status_info = licensing.license_status()
slots = _live_slots()
payload = {
**status_info,
'stations_used': slots.count(),
'components': licensing.machine_components(),
}
if request.user.is_admin:
payload['slots'] = [
{'station_key': s.station_key, 'com_port': s.com_port,
'bridge_version': s.bridge_version, 'last_seen': s.last_seen}
for s in slots
]
return Response(payload)
def post(self, request):
if not request.user.is_admin:
return Response({'detail': 'Only administrators can install a licence.'},
status=status.HTTP_403_FORBIDDEN)
blob = (request.data.get('key_blob') or '').strip()
if not blob:
return Response({'detail': 'key_blob is required'},
status=status.HTTP_400_BAD_REQUEST)
# Validate before storing, so a bad paste cannot displace a working licence.
valid, payload, reason = licensing.verify_license(blob)
if not valid:
return Response({'detail': reason}, status=status.HTTP_400_BAD_REQUEST)
record = License.objects.first() or License()
record.key_blob = blob
record.grace_started = None
record.save()
return Response(licensing.license_status())
class LicenseHeartbeatView(APIView):
"""Stations report in here so the server can count them.
The bridge deliberately does not talk to Django - the browser, which already
talks to both, reports which bridge it is using. That keeps the bridge pure
transport with no startup dependency on the server.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
station_key = (request.data.get('station_key') or '').strip()
if not station_key:
return Response({'detail': 'station_key is required'},
status=status.HTTP_400_BAD_REQUEST)
limit = licensing.effective_station_limit()
existing = StationSlot.objects.filter(station_key=station_key).first()
live = _live_slots()
if existing is None and live.count() >= limit:
return Response({
'allowed': False,
'stations_used': live.count(),
'stations_licensed': limit,
'detail': f'Licence limit reached ({limit} stations)',
}, status=status.HTTP_403_FORBIDDEN)
slot, _ = StationSlot.objects.update_or_create(
station_key=station_key,
defaults={
'bridge_version': request.data.get('bridge_version', '') or '',
'com_port': request.data.get('com_port', '') or '',
},
)
# update_or_create does not touch auto_now when nothing changed
slot.save(update_fields=['last_seen'])
return Response({
'allowed': True,
'stations_used': _live_slots().count(),
'stations_licensed': limit,
})
class ScaleProtocolViewSet(viewsets.ModelViewSet):
"""
API endpoint for scale protocol definitions.
The frontend caches these and uses them to parse raw serial lines, so adding
a scale type needs no change to the serial bridge running on station PCs.
Built-in protocols cannot be deleted.
"""
queryset = ScaleProtocol.objects.all()
serializer_class = ScaleProtocolSerializer
permission_classes = [IsAuthenticated]
search_fields = ['name']
ordering = ['name']
def perform_create(self, serializer):
instance = serializer.save()
sse_broadcast_update('scale_protocol', 'created', ScaleProtocolSerializer(instance).data)
def perform_update(self, serializer):
instance = serializer.save()
sse_broadcast_update('scale_protocol', 'updated', ScaleProtocolSerializer(instance).data)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.is_builtin:
return Response({'detail': 'Built-in protocols cannot be deleted.'},
status=status.HTTP_400_BAD_REQUEST)
data = ScaleProtocolSerializer(instance).data
instance.delete()
sse_broadcast_update('scale_protocol', 'deleted', data)
return Response(status=status.HTTP_204_NO_CONTENT)
class NomenclatureViewSet(viewsets.ModelViewSet): class NomenclatureViewSet(viewsets.ModelViewSet):
""" """
@@ -385,7 +758,7 @@ class ReportViewSet(viewsets.ModelViewSet):
queryset = Report.objects.select_related('created_by').all() queryset = Report.objects.select_related('created_by').all()
serializer_class = ReportSerializer serializer_class = ReportSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
filterset_fields = ['name'] filterset_fields = ['name', 'kind']
search_fields = ['name'] search_fields = ['name']
ordering = ['-updated_at'] ordering = ['-updated_at']
+8 -2
View File
@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Nomenclature, NomenclatureField, NomenclatureEntry from .models import Nomenclature, NomenclatureField, NomenclatureEntry, SelectorOption
class NomenclatureFieldInline(admin.TabularInline): class NomenclatureFieldInline(admin.TabularInline):
@@ -7,12 +7,18 @@ class NomenclatureFieldInline(admin.TabularInline):
extra = 1 extra = 1
class SelectorOptionInline(admin.TabularInline):
model = SelectorOption
extra = 1
filter_horizontal = ['reveals']
@admin.register(Nomenclature) @admin.register(Nomenclature)
class NomenclatureAdmin(admin.ModelAdmin): class NomenclatureAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order'] list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order']
list_filter = ['applies_to', 'kind'] list_filter = ['applies_to', 'kind']
search_fields = ['code', 'name'] search_fields = ['code', 'name']
inlines = [NomenclatureFieldInline] inlines = [NomenclatureFieldInline, SelectorOptionInline]
@admin.register(NomenclatureEntry) @admin.register(NomenclatureEntry)
@@ -0,0 +1,64 @@
# Generated by Django 6.0.1 on 2026-09-18 16:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("nomenclatures", "0002_alter_nomenclature_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="nomenclature",
name="kind",
field=models.CharField(
choices=[
("lookup", "Lookup Table"),
("field", "Custom Field"),
("selector", "Selector"),
],
default="lookup",
max_length=10,
),
),
migrations.CreateModel(
name="SelectorOption",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("value", models.CharField(max_length=50)),
("label", models.CharField(max_length=255)),
("sort_order", models.IntegerField(default=0)),
(
"reveals",
models.ManyToManyField(
blank=True,
related_name="revealed_by",
to="nomenclatures.nomenclature",
),
),
(
"selector",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="selector_options",
to="nomenclatures.nomenclature",
),
),
],
options={
"ordering": ["sort_order", "id"],
"unique_together": {("selector", "value")},
},
),
]
+27
View File
@@ -11,9 +11,11 @@ class Nomenclature(models.Model):
LOOKUP = "lookup" LOOKUP = "lookup"
FIELD = "field" FIELD = "field"
SELECTOR = "selector"
KIND_CHOICES = [ KIND_CHOICES = [
(LOOKUP, "Lookup Table"), (LOOKUP, "Lookup Table"),
(FIELD, "Custom Field"), (FIELD, "Custom Field"),
(SELECTOR, "Selector"),
] ]
code = models.CharField(max_length=50, unique=True) code = models.CharField(max_length=50, unique=True)
@@ -35,6 +37,31 @@ class Nomenclature(models.Model):
return f"{self.name} ({self.code})" return f"{self.name} ({self.code})"
class SelectorOption(models.Model):
"""One choice of a selector, and the nomenclatures it reveals."""
selector = models.ForeignKey(
Nomenclature,
on_delete=models.CASCADE,
related_name="selector_options",
)
value = models.CharField(max_length=50)
label = models.CharField(max_length=255)
reveals = models.ManyToManyField(
Nomenclature,
blank=True,
related_name="revealed_by",
)
sort_order = models.IntegerField(default=0)
class Meta:
ordering = ["sort_order", "id"]
unique_together = [("selector", "value")]
def __str__(self):
return f"{self.selector.code}={self.value}"
class NomenclatureField(models.Model): class NomenclatureField(models.Model):
TEXT = "text" TEXT = "text"
NUMBER = "number" NUMBER = "number"
+48
View File
@@ -0,0 +1,48 @@
"""The one rule deciding which nomenclatures are visible for a given extra_data.
A nomenclature is hidden if it is revealed by some selector option and that
selector's current value is not the option revealing it. Anything no selector
references is always visible, so installations without selectors are unchanged.
Mirrored in frontend/src/utils/nomenclatureVisibility.js - keep the two in step.
"""
from .models import Nomenclature, SelectorOption
def build_reveal_map(nomenclatures=None):
"""{revealed_code: {selector_code: {value, ...}}} for the given nomenclatures."""
options = (
SelectorOption.objects
.select_related('selector')
.prefetch_related('reveals')
)
if nomenclatures is not None:
codes = {n.code for n in nomenclatures}
options = [o for o in options if o.selector.code in codes]
reveal_map = {}
for option in options:
for revealed in option.reveals.all():
reveal_map.setdefault(revealed.code, {}).setdefault(
option.selector.code, set()
).add(option.value)
return reveal_map
def is_visible(code, extra_data, reveal_map):
"""True if the nomenclature `code` should be shown/accepted for this data."""
controllers = reveal_map.get(code)
if not controllers:
return True
for selector_code, revealing_values in controllers.items():
if extra_data.get(selector_code) in revealing_values:
return True
return False
def visible_codes(codes, extra_data, reveal_map=None):
"""Filter `codes` down to those visible for `extra_data`."""
if reveal_map is None:
reveal_map = build_reveal_map()
return [c for c in codes if is_visible(c, extra_data or {}, reveal_map)]
+2
View File
@@ -2,6 +2,7 @@ Django==6.0.1
djangorestframework==3.15.2 djangorestframework==3.15.2
djangorestframework-simplejwt==5.5.1 djangorestframework-simplejwt==5.5.1
django-cors-headers==4.6.0 django-cors-headers==4.6.0
django-filter==26.1
python-dotenv==1.0.1 python-dotenv==1.0.1
requests==2.32.3 requests==2.32.3
sqlparse==0.5.3 sqlparse==0.5.3
@@ -9,3 +10,4 @@ psycopg==3.3.2
psycopg-binary==3.3.2 psycopg-binary==3.3.2
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
whitenoise==6.9.0 whitenoise==6.9.0
cryptography==50.0.1
+3 -1
View File
@@ -32,6 +32,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken', 'rest_framework.authtoken',
'rest_framework_simplejwt', 'rest_framework_simplejwt',
'corsheaders', 'corsheaders',
'django_filters',
] + PROJECT_APPS ] + PROJECT_APPS
MIDDLEWARE = [ MIDDLEWARE = [
@@ -105,7 +106,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'api.User' AUTH_USER_MODEL = 'api.User'
# CORS Configuration # CORS Configuration
_cors_default = 'http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174' _cors_default = 'http://localhost:5172,http://127.0.0.1:5172,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174'
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', _cors_default).split(',') CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', _cors_default).split(',')
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [ CORS_ALLOW_HEADERS = [
@@ -139,6 +140,7 @@ REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100, 'PAGE_SIZE': 100,
'DEFAULT_FILTER_BACKENDS': [ 'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter', 'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter', 'rest_framework.filters.OrderingFilter',
], ],
@@ -0,0 +1,40 @@
# Generated by Django 6.0.1 on 2026-09-17 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"vehicles",
"0004_vehicle_doc_number_vehicle_gross_manual_vehicle_net_and_more",
),
]
operations = [
migrations.CreateModel(
name="ExtraCombination",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("data", models.JSONField(default=dict)),
(
"data_hash",
models.CharField(editable=False, max_length=64, unique=True),
),
("use_count", models.IntegerField(default=1)),
("last_used", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["-last_used"],
},
),
]
@@ -0,0 +1,58 @@
# Generated by Django 6.0.1 on 2026-09-17 14:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("vehicles", "0005_extracombination"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="vehicle",
name="gross_manual_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="vehicle",
name="gross_manual_note",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="vehicle",
name="gross_manual_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_vehicle_gross_manual",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="vehicle",
name="tare_manual_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="vehicle",
name="tare_manual_note",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="vehicle",
name="tare_manual_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_vehicle_tare_manual",
to=settings.AUTH_USER_MODEL,
),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-09-21 16:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"vehicles",
"0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more",
),
]
operations = [
migrations.AddField(
model_name="vehicle",
name="driver_name",
field=models.CharField(blank=True, max_length=50, null=True),
),
]
@@ -0,0 +1,55 @@
# Generated by Django 6.0.1 on 2026-09-22 17:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("vehicles", "0007_vehicle_driver_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="vehicle",
name="is_void",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="vehicle",
name="void_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="vehicle",
name="void_reason",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="vehicle",
name="void_user",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user_vehicle_void",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="vehicle",
name="vehicle_number",
field=models.CharField(max_length=15),
),
migrations.AddConstraint(
model_name="vehicle",
constraint=models.UniqueConstraint(
condition=models.Q(("doc_number__isnull", True)),
fields=("vehicle_number",),
name="unique_active_vehicle_number",
),
),
]
+47 -1
View File
@@ -1,3 +1,6 @@
import hashlib
import json
from typing import Required from typing import Required
from django.db import models from django.db import models
@@ -5,20 +8,27 @@ from api.models import User
# Create your models here. # Create your models here.
class Vehicle(models.Model): class Vehicle(models.Model):
vehicle_number = models.CharField(max_length=15, unique=True) vehicle_number = models.CharField(max_length=15)
trailer1_number = models.CharField(max_length=15, null=True, blank=True) trailer1_number = models.CharField(max_length=15, null=True, blank=True)
trailer2_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) driver_pid = models.CharField(max_length=50, null=True, blank=True)
driver_name = models.CharField(max_length=50, null=True, blank=True)
tare = models.IntegerField(null=True, blank=True) tare = models.IntegerField(null=True, blank=True)
tare_date = models.DateTimeField(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) tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL)
tare_manual = models.BooleanField(default=False) tare_manual = models.BooleanField(default=False)
tare_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare_manual', on_delete=models.SET_NULL)
tare_manual_date = models.DateTimeField(null=True, blank=True)
tare_manual_note = models.CharField(max_length=255, null=True, blank=True)
gross = models.IntegerField(null=True, blank=True) gross = models.IntegerField(null=True, blank=True)
gross_date = models.DateTimeField(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) gross_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross', on_delete=models.SET_NULL)
gross_manual = models.BooleanField(default=False) gross_manual = models.BooleanField(default=False)
gross_manual_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_gross_manual', on_delete=models.SET_NULL)
gross_manual_date = models.DateTimeField(null=True, blank=True)
gross_manual_note = models.CharField(max_length=255, null=True, blank=True)
net = models.IntegerField(null=True, blank=True) net = models.IntegerField(null=True, blank=True)
net_date = models.DateTimeField(null=True, blank=True) net_date = models.DateTimeField(null=True, blank=True)
@@ -26,7 +36,19 @@ class Vehicle(models.Model):
doc_number = models.CharField(max_length=20, null=True, blank=True, unique=True) doc_number = models.CharField(max_length=20, null=True, blank=True, unique=True)
is_void = models.BooleanField(default=False)
void_date = models.DateTimeField(null=True, blank=True)
void_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_void', on_delete=models.SET_NULL)
void_reason = models.CharField(max_length=255, null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['vehicle_number'],
condition=models.Q(doc_number__isnull=True),
name='unique_active_vehicle_number',
),
]
def __str__(self): def __str__(self):
return f"{self.vehicle_number}" return f"{self.vehicle_number}"
@@ -36,3 +58,27 @@ class VehicleExtra(models.Model):
Vehicle, on_delete=models.CASCADE, related_name="extra" Vehicle, on_delete=models.CASCADE, related_name="extra"
) )
data = models.JSONField(default=dict) data = models.JSONField(default=dict)
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})"
+7 -1
View File
@@ -4,4 +4,10 @@ from .models import Vehicle
class VehicleSerializer(serializers.ModelSerializer): class VehicleSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Vehicle model = Vehicle
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user'] fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user']
def validate(self, attrs):
"""Require driver_name on creation only; existing vehicles may have it as NULL."""
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs
+2 -2
View File
@@ -38,8 +38,8 @@ services:
build: build:
context: ./frontend context: ./frontend
args: args:
REACT_APP_API_URL: ${REACT_APP_API_URL} VITE_API_URL: ${VITE_API_URL}
REACT_APP_SERIAL_URL: ${REACT_APP_SERIAL_URL} VITE_SERIAL_URL: ${VITE_SERIAL_URL}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8100:80" - "8100:80"
+1 -1
View File
@@ -1 +1 @@
REACT_APP_API_URL=http://localhost:8000 VITE_API_URL=http://localhost:8002
+4 -4
View File
@@ -9,10 +9,10 @@ RUN npm install
COPY . . COPY . .
# These are baked into the JS bundle at build time # These are baked into the JS bundle at build time
ARG REACT_APP_API_URL ARG VITE_API_URL
ARG REACT_APP_SERIAL_URL ARG VITE_SERIAL_URL
ENV REACT_APP_API_URL=$REACT_APP_API_URL ENV VITE_API_URL=$VITE_API_URL
ENV REACT_APP_SERIAL_URL=$REACT_APP_SERIAL_URL ENV VITE_SERIAL_URL=$VITE_SERIAL_URL
RUN npm run build RUN npm run build
@@ -13,5 +13,6 @@
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body> </body>
</html> </html>
-11
View File
@@ -1,14 +1,3 @@
{ {
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@components/*": ["components/*"],
"@contexts/*": ["contexts/*"],
"@hooks/*": ["hooks/*"],
"@utils/*": ["utils/*"],
"@services/*": ["services/*"],
"@assets/*": ["assets/*"]
}
},
"include": ["src"] "include": ["src"]
} }
+1383 -15236
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -3,23 +3,18 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"axios": "^1.6.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"axios": "^1.6.2",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"recharts": "^2.10.3" "recharts": "^2.10.3"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "vite",
"build": "react-scripts build", "dev": "vite",
"test": "react-scripts test", "build": "vite build",
"eject": "react-scripts eject" "preview": "vite preview",
}, "test": "vitest run"
"eslintConfig": {
"extends": [
"react-app"
]
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@@ -33,5 +28,11 @@
"last 1 safari version" "last 1 safari version"
] ]
}, },
"proxy": "http://localhost:8000" "devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^8.57.1",
"jsdom": "^29.1.1",
"vite": "^8.3.0",
"vitest": "^5.0.1"
}
} }
+25 -25
View File
@@ -1,68 +1,68 @@
.App { .App {
min-height: 100vh; min-height: 100vh;
background-color: #f5f5f5; background-color: var(--bg);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.status-bar { .status-bar {
background: white; background: var(--surface);
padding: 12px 20px; padding: var(--space-3) var(--space-5);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: var(--space-5);
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: var(--shadow-card);
} }
.status-connected { .status-connected {
color: #4CAF50; color: var(--success);
font-size: 14px; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
} }
.status-disconnected { .status-disconnected {
color: #f44336; color: var(--danger);
font-size: 14px; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
} }
.status-error { .status-error {
color: #f44336; color: var(--danger);
font-size: 12px; font-size: var(--fs-sm);
} }
.container { .container {
max-width: 1200px; max-width: 1200px;
margin: 20px auto; margin: var(--space-5) auto;
padding: 0 20px; padding: 0 var(--space-5);
flex: 1; flex: 1;
width: 100%; width: 100%;
} }
.error-message { .error-message {
background-color: #f8d7da; background-color: var(--danger-soft);
color: #721c24; color: var(--danger);
padding: 12px 20px; padding: var(--space-3) var(--space-5);
border-radius: 4px; border-radius: 4px;
margin-bottom: 20px; margin-bottom: var(--space-5);
border: 1px solid #f5c6cb; border: 1px solid var(--danger);
} }
.loading { .loading {
text-align: center; text-align: center;
padding: 40px; padding: var(--space-10);
font-size: 18px; font-size: var(--fs-xl);
color: #666; color: var(--text-muted);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.status-bar { .status-bar {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: var(--space-2);
} }
.container { .container {
padding: 0 10px; padding: 0 var(--space-2-5);
} }
} }
+49 -2
View File
@@ -1,14 +1,30 @@
import React, { useState } from 'react'; import React, { useState, Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext'; import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
// import ProtectedRoute from './components/ProtectedRoute'; // import ProtectedRoute from './components/ProtectedRoute';
import Login from './components/Users/Login'; import Login from './components/Users/Login';
import Main from './components/Main'; import Main from './components/Main';
import ReportEditor from './components/ReportEditor/ReportEditor'; import ReportEditor from './components/ReportEditor/ReportEditor';
import UserManager from './components/Users/UserManager'; import UserManager from './components/Users/UserManager';
import History from './components/History';
import RailLayout from './components/RailLayout';
import './App.css'; import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext'; import { NomenclatureProvider } from './contexts/NomenclatureContext';
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext'; import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
import { ScaleProtocolProvider } from './contexts/ScaleProtocolContext';
import ScaleMonitor from './components/ScaleMonitor/ScaleMonitor';
import { LicenseProvider } from './contexts/LicenseContext';
import LicenseManager from './components/License/LicenseManager';
const A4ReportEditor = lazy(() => import('./components/ReportEditor/a4/A4ReportEditor'));
// UI concept preview — a standalone mockup at /concept/cyberpunk, deliberately
// OUTSIDE the auth gate and the providers. It ships its own :root variables and
// styles body/*, which would fight the app's theme tokens if it were ever
// rendered alongside the real UI; lazy-loading keeps its CSS out of the bundle
// until that route is opened. Nothing in the app imports it.
const ScalesAppCyberpunk = lazy(() => import('./components/concepts/ScalesAppCyberpunk'));
// function MainApp() { // function MainApp() {
// const [selectedPort, setSelectedPort] = useState(null); // const [selectedPort, setSelectedPort] = useState(null);
@@ -58,11 +74,23 @@ function AppContent() {
return ( return (
<NomenclatureProvider> <NomenclatureProvider>
<NomenclatureDataProvider> <NomenclatureDataProvider>
<ScaleProtocolProvider>
<LicenseProvider>
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
<Routes> <Routes>
<Route element={<RailLayout />}>
<Route path="/" element={<Main />} /> <Route path="/" element={<Main />} />
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} /> <Route path="/history" element={<History />} />
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} /> <Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
<Route path="/scale-monitor" element={currentUser?.is_admin ? <ScaleMonitor /> : <Navigate to="/" />} />
<Route path="/license" element={currentUser?.is_admin ? <LicenseManager /> : <Navigate to="/" />} />
</Route>
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
<Route path="/report-editor-a4" element={currentUser?.can_edit_documents ? <A4ReportEditor /> : <Navigate to="/" />} />
</Routes> </Routes>
</Suspense>
</LicenseProvider>
</ScaleProtocolProvider>
</NomenclatureDataProvider> </NomenclatureDataProvider>
</NomenclatureProvider> </NomenclatureProvider>
); );
@@ -72,9 +100,28 @@ function AppContent() {
function App() { function App() {
return ( return (
<Router> <Router>
<Routes>
{/* Concept preview, no auth and no providers — see the note above. */}
<Route
path="/concept/cyberpunk"
element={
<Suspense fallback={<div className="loading"><p>Loading concept...</p></div>}>
<ScalesAppCyberpunk />
</Suspense>
}
/>
<Route
path="*"
element={
/* Outermost, so the login screen and any overlay are themed too. */
<ThemeProvider>
<AuthProvider> <AuthProvider>
<AppContent /> <AppContent />
</AuthProvider> </AuthProvider>
</ThemeProvider>
}
/>
</Routes>
</Router> </Router>
); );
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

@@ -0,0 +1,129 @@
.extra-combos {
margin-bottom: var(--space-4);
}
.extra-combos h3 {
margin: 0 0 var(--space-2) 0;
font-size: var(--fs-sm);
color: var(--text-muted);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.extra-combos-error {
padding: var(--space-1-5) var(--space-2-5);
background-color: var(--danger-soft);
color: var(--danger);
border: 1px solid var(--danger);
border-radius: 6px;
font-size: var(--fs-sm);
margin-bottom: var(--space-2);
}
.extra-combos-table-container {
max-height: 180px;
overflow-y: auto;
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.extra-combos-table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.extra-combos-table-container::-webkit-scrollbar-track {
background: var(--surface-hover);
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.extra-combos-table {
width: 100%;
border-collapse: collapse;
}
.extra-combos-table thead {
background-color: var(--surface-sunken);
position: sticky;
top: 0;
z-index: 1;
}
.extra-combos-table th {
padding: var(--space-1-5) var(--space-3);
text-align: left;
font-weight: var(--fw-semibold);
color: var(--text-muted);
font-size: var(--fs-sm);
border-bottom: 2px solid var(--border);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.extra-combos-th-actions {
width: 36px;
}
.extra-combos-table td {
padding: var(--space-1-5) var(--space-3);
border-bottom: 1px solid var(--border);
font-size: var(--fs-md);
color: var(--text);
white-space: nowrap;
}
.extra-combos-row {
cursor: pointer;
transition: background-color 0.15s;
}
.extra-combos-row:hover {
background-color: var(--accent-soft);
}
.extra-combos-td-actions {
text-align: center;
}
.extra-combos-delete-btn {
background: none;
border: 1px solid var(--border);
padding: 0 var(--space-1-5);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: var(--fs-md);
line-height: 1.4;
color: var(--text-muted);
}
.extra-combos-delete-btn:hover {
background-color: var(--danger);
border-color: var(--danger);
color: var(--accent-contrast);
}
.extra-combos-stale {
color: var(--text-muted);
font-style: italic;
}
.extra-combos-empty {
text-align: center;
padding: var(--space-4) !important;
color: var(--text-muted);
font-style: italic;
font-size: var(--fs-md);
}
@@ -0,0 +1,132 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import api from '../services/api';
import './ExtraCombinationsTable.css';
export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
const { subscribeToUpdates } = useNomenclatures();
const { getDisplayValue } = useNomenclatureData();
const [combinations, setCombinations] = useState([]);
const [error, setError] = useState('');
// subscribeToUpdates is not memoized in NomenclatureContext - hold it in a ref
// so the SSE effect below runs once instead of on every provider render.
const subscribeRef = useRef(subscribeToUpdates);
subscribeRef.current = subscribeToUpdates;
// Lookups and selectors form a combination: the selector value is what makes
// "Ship + cargo" and "Warehouse + cargo" distinct rows rather than one.
const lookupNomenclatures = nomenclatures.filter(
n => n.kind === 'lookup' || n.kind === 'selector'
);
// A selector stores its option value, not an entry id, so it resolves to the
// option's label instead of going through getDisplayValue.
const cellLabel = (nom, value) => {
if (nom.kind !== 'selector') return getDisplayValue(nom.code, value);
const option = (nom.selector_options || []).find(o => o.value === value);
return option ? option.label : '';
};
const load = useCallback(async () => {
try {
const res = await api.get('/api/extra-combinations/');
setCombinations(res.data.results || res.data);
} catch {
setError('Failed to load saved combinations');
}
}, []);
useEffect(() => { load(); }, [load]);
// Realtime: rows are created/bumped by the backend when extra data is saved
useEffect(() => {
const unsubscribe = subscribeRef.current((message) => {
if (message.object !== 'extra_combination') return;
const { operation, data } = message;
setCombinations(prev => {
if (operation === 'delete') {
return prev.filter(c => c.id !== data.id);
}
// insert or update: drop any existing copy, put it on top (most recent first)
return [data, ...prev.filter(c => c.id !== data.id)];
});
});
return unsubscribe;
}, []);
const handleDelete = async (e, id) => {
e.stopPropagation();
if (!window.confirm('Delete this combination?')) return;
setError('');
try {
await api.delete(`/api/extra-combinations/${id}/`);
} catch {
setError('Failed to delete combination');
}
};
if (lookupNomenclatures.length === 0) return null;
return (
<div className="extra-combos">
<h3>Previously Used</h3>
{error && <div className="extra-combos-error">{error}</div>}
<div className="extra-combos-table-container">
<table className="extra-combos-table">
<thead>
<tr>
{lookupNomenclatures.map(nom => (
<th key={nom.code}>{nom.name}</th>
))}
<th className="extra-combos-th-actions"></th>
</tr>
</thead>
<tbody>
{combinations.length === 0 ? (
<tr>
<td colSpan={lookupNomenclatures.length + 1} className="extra-combos-empty">
No saved combinations yet
</td>
</tr>
) : (
combinations.map(combo => (
<tr
key={combo.id}
className="extra-combos-row"
onClick={() => onApply(combo.data)}
title="Click to fill the fields below"
>
{lookupNomenclatures.map(nom => {
const entryId = combo.data[nom.code];
const label = entryId != null ? cellLabel(nom, entryId) : '';
return (
<td key={nom.code}>
{entryId != null && !label
? <span className="extra-combos-stale">#{entryId}</span>
: label}
</td>
);
})}
<td className="extra-combos-td-actions">
<button
className="extra-combos-delete-btn"
onClick={(e) => handleDelete(e, combo.id)}
title="Delete combination"
type="button"
>
&times;
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
-139
View File
@@ -1,139 +0,0 @@
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.nav-button {
width: 42px;
height: 42px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
font-size: 20px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: 10px;
}
.user-name {
font-weight: 600;
font-size: 14px;
}
.user-role {
font-size: 12px;
opacity: 0.9;
}
.avatar-container {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: white;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.avatar:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.logout-button {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.3);
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.header-left h1 {
font-size: 18px;
text-align: center;
}
.header-right {
justify-content: space-between;
}
.user-info {
align-items: flex-start;
margin-right: 0;
}
}
-87
View File
@@ -1,87 +0,0 @@
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) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
const handleAvatarClick = () => {
setShowPasswordOverlay(true);
};
return (
<>
<header className="app-header">
<div className="header-content">
<div className="header-left">
{currentUser?.can_edit_documents && (
<button
className="nav-button"
onClick={() => navigate('/report-editor')}
title="Report Editor"
>
📝
</button>
)}
{currentUser?.can_manage_entities && (
<button
className="nav-button"
onClick={() => setShowNomenclatureManager(true)}
title="Nomenclatures"
>
📋
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/users')}
title="User Manager"
>
👥
</button>
)}
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
<div className="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
</div>
<div className="avatar-container">
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
{getInitials(currentUser || {})}
</div>
<button className="logout-button" onClick={logout} title="Logout">
Logout
</button>
</div>
</div>
</div>
</header>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
export default Header;
+182
View File
@@ -0,0 +1,182 @@
.history {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-6) var(--space-5);
}
.history-title {
margin: 0 0 var(--space-4) 0;
color: var(--text);
}
.history-error {
padding: var(--space-2-5) var(--space-4);
margin-bottom: var(--space-3);
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-sm);
font-size: var(--fs-base);
}
.history-table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.history-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-md);
}
.history-table th {
text-align: left;
padding: var(--space-2-5) var(--space-4);
background: var(--surface-hover);
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
.history-table td {
padding: var(--space-2-5) var(--space-4);
border-bottom: 1px solid var(--border);
color: var(--text);
}
.history-row {
cursor: pointer;
}
.history-row:hover {
background: var(--surface-hover);
}
.history-row:last-child td {
border-bottom: none;
}
.history-empty {
text-align: center;
color: var(--text-muted);
padding: var(--space-8) !important;
}
.history-badge {
display: inline-block;
padding: var(--space-1) var(--space-2-5);
border: 1px solid;
border-radius: 20px;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
letter-spacing: 0.03em;
white-space: nowrap;
}
.history-badge--ok {
color: var(--success);
border-color: var(--success);
background: var(--success-soft);
}
.history-badge--void {
color: var(--danger);
border-color: var(--danger);
background: var(--danger-soft);
}
.history-void-btn {
padding: var(--space-1) var(--space-3);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: var(--fs-sm);
cursor: pointer;
}
.history-void-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.history-void-inline {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
min-width: 180px;
}
.history-void-inline input {
padding: var(--space-1-5) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
box-sizing: border-box;
}
.history-void-error {
color: var(--danger);
font-size: var(--fs-xs);
}
.history-void-actions {
display: flex;
gap: var(--space-1-5);
}
.history-void-actions button {
padding: var(--space-1) var(--space-2-5);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-hover);
color: var(--text);
font-size: var(--fs-sm);
cursor: pointer;
}
.history-pager {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
margin-top: var(--space-4);
}
.history-pager button {
min-width: 32px;
height: 32px;
padding: 0 var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: var(--fs-base);
cursor: pointer;
}
.history-pager button:hover:not(:disabled) {
border-color: var(--border-strong);
}
.history-pager button:disabled {
cursor: default;
opacity: 0.5;
}
.history-pager .history-page-btn--active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
opacity: 1;
}
.history-page-gap {
color: var(--text-muted);
padding: 0 var(--space-1);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import { buildVehicleReportData } from '../services/vehicleReportData';
import ReportPreviewModal from './ReportPreviewModal';
import api from '../services/api';
import './History.css';
const PAGE_SIZE = 100; // matches the backend's DRF PAGE_SIZE
export default function History() {
const { currentUser } = useAuth();
const { definitions, entries } = useNomenclatureData();
const [vehicles, setVehicles] = useState([]);
const [count, setCount] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// django-filter's OrderingFilter has no explicit `ordering_fields` set on
// VehicleViewSet, so it falls back to allowing any non-write-only
// serializer field - net_date included - meaning ?ordering=-net_date
// works server-side today. No client-side fallback sort is needed.
const [previewVehicle, setPreviewVehicle] = useState(null);
const [voidingId, setVoidingId] = useState(null);
const [voidReason, setVoidReason] = useState('');
const [voidError, setVoidError] = useState('');
const [isVoiding, setIsVoiding] = useState(false);
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
// Windowed page list (first, last, and a range around the current page)
// so the pager stays usable once History accumulates hundreds of pages -
// nothing in this feature ever deletes a vehicle, so unbounded growth is
// the expected long-run case, not an edge case.
const pageList = useMemo(() => {
const spread = 2;
const pages = new Set([1, totalPages]);
for (let n = page - spread; n <= page + spread; n++) {
if (n >= 1 && n <= totalPages) pages.add(n);
}
const sorted = [...pages].sort((a, b) => a - b);
const withGaps = [];
sorted.forEach((n, i) => {
if (i > 0 && n - sorted[i - 1] > 1) withGaps.push('...');
withGaps.push(n);
});
return withGaps;
}, [page, totalPages]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
api.get('/api/vehicles/', {
params: { 'doc_number__isnull': false, ordering: '-net_date', page },
})
.then(res => {
if (cancelled) return;
setVehicles(res.data.results || []);
setCount(res.data.count || 0);
})
.catch(err => {
if (cancelled) return;
setError(err.response?.data?.detail || 'Failed to load history');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [page]);
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const openPreview = (vehicle) => {
setPreviewVehicle(vehicle);
};
const previewData = useMemo(
() => previewVehicle ? buildVehicleReportData(previewVehicle, definitions, entries) : null,
[previewVehicle, definitions, entries]
);
const startVoid = (vehicle) => {
setVoidingId(vehicle.id);
setVoidReason('');
setVoidError('');
};
const cancelVoid = () => {
setVoidingId(null);
setVoidReason('');
setVoidError('');
};
const confirmVoid = async () => {
if (!voidReason.trim()) {
setVoidError('A reason is required to void a document.');
return;
}
if (!window.confirm('Void this document? This flags it permanently and cannot be undone casually.')) {
return;
}
setIsVoiding(true);
setVoidError('');
try {
const res = await api.post(`/api/vehicles/${voidingId}/void/`, { reason: voidReason.trim() });
// History has its own separate fetch/state (not NomenclatureContext's
// vehicles array), so the voided row is updated in place here from
// the API response rather than relying on the SSE 'voided' handler.
setVehicles(prev => prev.map(v => v.id === res.data.id ? res.data : v));
cancelVoid();
} catch (err) {
setVoidError(err.response?.data?.detail || 'Failed to void document');
} finally {
setIsVoiding(false);
}
};
return (
<div>
<div className="history">
<h2 className="history-title">History</h2>
{error && <div className="history-error">{error}</div>}
<div className="history-table-wrap">
<table className="history-table">
<thead>
<tr>
<th>Vehicle</th>
<th>Doc #</th>
<th>Net (kg)</th>
<th>Date</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="history-empty">Loading...</td></tr>
) : vehicles.length === 0 ? (
<tr><td colSpan={6} className="history-empty">No completed vehicles yet</td></tr>
) : (
vehicles.map(v => (
<tr key={v.id} className="history-row" onClick={() => openPreview(v)}>
<td>{v.vehicle_number}</td>
<td>{v.doc_number}</td>
<td>{v.net != null ? v.net : '—'}</td>
<td>{formatDate(v.net_date)}</td>
<td>
<span className={`history-badge ${v.is_void ? 'history-badge--void' : 'history-badge--ok'}`}>
{v.is_void ? 'Voided' : 'Completed'}
</span>
</td>
<td onClick={e => e.stopPropagation()}>
{currentUser?.can_edit_documents && !v.is_void && (
voidingId === v.id ? (
<div className="history-void-inline">
<input
type="text"
placeholder="Reason..."
value={voidReason}
onChange={e => setVoidReason(e.target.value)}
autoFocus
/>
{voidError && <div className="history-void-error">{voidError}</div>}
<div className="history-void-actions">
<button onClick={confirmVoid} disabled={isVoiding}>
{isVoiding ? 'Voiding...' : 'Confirm'}
</button>
<button onClick={cancelVoid} disabled={isVoiding}>Cancel</button>
</div>
</div>
) : (
<button className="history-void-btn" onClick={() => startVoid(v)}>
Void
</button>
)
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="history-pager">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
>
‹
</button>
{pageList.map((n, i) => (
n === '...' ? (
<span key={`gap-${i}`} className="history-page-gap">…</span>
) : (
<button
key={n}
className={n === page ? 'history-page-btn--active' : ''}
onClick={() => setPage(n)}
disabled={n === page}
>
{n}
</button>
)
))}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
›
</button>
</div>
)}
</div>
{previewData && (
<ReportPreviewModal
vehicleData={previewData}
onClose={() => setPreviewVehicle(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,246 @@
.license-manager {
flex: 1 0 auto; /* fill the rail layout's content column */
background-color: var(--surface-sunken);
}
.lm-body {
padding: var(--space-5);
max-width: 800px;
margin: 0 auto;
}
.lm-loading,
.lm-empty {
padding: var(--space-6);
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.lm-toolbar {
margin-bottom: var(--space-4);
}
.lm-back {
padding: var(--space-1-5) var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
font-size: var(--fs-base);
}
.lm-back:hover {
border-color: var(--accent);
color: var(--accent);
}
.lm-ok,
.lm-error {
padding: var(--space-2) var(--space-3);
border-radius: 6px;
font-size: var(--fs-base);
margin-bottom: var(--space-3);
}
.lm-ok {
background-color: var(--success-soft);
border: 1px solid var(--success);
color: var(--success);
}
.lm-error {
background-color: var(--danger-soft);
border: 1px solid var(--danger);
color: var(--danger);
}
.lm-panel {
background: var(--surface);
border-radius: 8px;
padding: var(--space-5);
margin-bottom: var(--space-4);
box-shadow: var(--shadow-card);
}
.lm-panel h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lm-badge {
display: inline-block;
padding: var(--space-1) var(--space-2-5);
border-radius: 4px;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--space-2-5);
}
.lm-badge--ok {
background-color: var(--success-soft);
color: var(--success);
}
.lm-badge--warn {
background-color: var(--warning-soft);
color: var(--warning);
}
.lm-facts {
display: grid;
grid-template-columns: 140px 1fr;
gap: var(--space-1-5) var(--space-3);
margin: 0;
}
.lm-facts dt {
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: center;
}
.lm-facts dd {
margin: 0;
font-size: var(--fs-md);
color: var(--text);
}
.lm-reason {
margin: 0 0 var(--space-2) 0;
font-size: var(--fs-md);
color: var(--text);
}
.lm-grace {
margin: 0;
font-size: var(--fs-base);
color: var(--text-muted);
line-height: 1.5;
}
.lm-hint {
margin: 0 0 var(--space-3) 0;
font-size: var(--fs-base);
color: var(--text-muted);
line-height: 1.5;
}
.lm-fingerprint {
display: flex;
align-items: center;
gap: var(--space-2);
}
.lm-fingerprint code {
flex: 1;
padding: var(--space-2-5) var(--space-3);
background-color: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: var(--fs-xl);
letter-spacing: 1px;
color: var(--text);
word-break: break-all;
}
.lm-components {
font-size: var(--fs-xs) !important;
letter-spacing: 0 !important;
}
.lm-fingerprint button {
padding: var(--space-2) var(--space-4);
background: none;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: var(--fs-base);
cursor: pointer;
flex-shrink: 0;
}
.lm-fingerprint button:hover {
border-color: var(--accent);
color: var(--accent);
}
.lm-details {
margin-top: var(--space-2-5);
}
.lm-details summary {
font-size: var(--fs-sm);
color: var(--text-muted);
cursor: pointer;
margin-bottom: var(--space-2);
}
.lm-blob {
width: 100%;
padding: var(--space-2-5);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font-mono);
font-size: var(--fs-sm);
box-sizing: border-box;
resize: vertical;
}
.lm-install {
margin-top: var(--space-2-5);
padding: var(--space-2-5) var(--space-5);
background: var(--accent-gradient);
border: none;
border-radius: 6px;
color: var(--accent-contrast);
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.lm-install:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px var(--accent-soft);
}
.lm-install:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lm-table {
width: 100%;
border-collapse: collapse;
}
.lm-table th {
padding: var(--space-2) var(--space-3);
text-align: left;
font-weight: var(--fw-semibold);
color: var(--text);
font-size: var(--fs-sm);
border-bottom: 2px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.3px;
background-color: var(--surface-sunken);
}
.lm-table td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
font-size: var(--fs-md);
color: var(--text);
font-family: var(--font-mono);
}
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLicense } from '../../contexts/LicenseContext';
import api from '../../services/api';
import './LicenseManager.css';
export default function LicenseManager() {
const navigate = useNavigate();
const { status, refreshStatus } = useLicense();
const [blob, setBlob] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState('');
const copy = (text, label) => {
navigator.clipboard?.writeText(text).then(
() => { setCopied(label); setTimeout(() => setCopied(''), 2000); },
() => setError('Could not copy - select and copy manually')
);
};
const handleInstall = async () => {
if (!blob.trim()) return;
setSaving(true); setError(''); setMessage('');
try {
await api.post('/api/license/', { key_blob: blob.trim() });
await refreshStatus();
setBlob('');
setMessage('Licence installed.');
} catch (err) {
setError(err.response?.data?.detail || 'Could not install licence');
} finally {
setSaving(false);
}
};
if (!status) {
return (
<div className="license-manager">
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
</div>
);
}
const componentsJson = JSON.stringify(status.components || {});
return (
<div className="license-manager">
<div className="lm-body">
<div className="lm-toolbar">
<button className="lm-back" onClick={() => navigate('/')}>&larr; Back</button>
</div>
{message && <div className="lm-ok">{message}</div>}
{error && <div className="lm-error">{error}</div>}
{/* Current state */}
<div className="lm-panel">
<h3>Licence</h3>
{status.valid ? (
<div className="lm-state lm-state--ok">
<div className="lm-badge lm-badge--ok">Licensed</div>
<dl className="lm-facts">
<dt>Customer</dt><dd>{status.customer || '—'}</dd>
<dt>Stations</dt>
<dd>{status.stations_used} of {status.stations} in use</dd>
</dl>
</div>
) : (
<div className="lm-state lm-state--warn">
<div className="lm-badge lm-badge--warn">
{status.in_grace ? 'Not licensed' : 'Licence expired'}
</div>
<p className="lm-reason">{status.reason}</p>
{status.in_grace ? (
<p className="lm-grace">
Everything keeps working for <strong>{status.grace_days_left} more days</strong>.
After that only {1} station can measure. Send the fingerprint below to
your supplier to get a licence key.
</p>
) : (
<p className="lm-grace">
The grace period has ended, so only {status.stations} station can measure.
Send the fingerprint below to your supplier.
</p>
)}
</div>
)}
</div>
{/* Fingerprint to send */}
<div className="lm-panel">
<h3>This machine</h3>
<p className="lm-hint">
Send this to your supplier to obtain a licence key. It identifies this
server only &mdash; the values are one-way hashes, not hardware serial numbers.
</p>
<div className="lm-fingerprint">
<code>{status.fingerprint}</code>
<button onClick={() => copy(status.fingerprint, 'fingerprint')}>
{copied === 'fingerprint' ? 'Copied' : 'Copy'}
</button>
</div>
<details className="lm-details">
<summary>Full details (needed to issue the key)</summary>
<div className="lm-fingerprint">
<code className="lm-components">{componentsJson}</code>
<button onClick={() => copy(componentsJson, 'components')}>
{copied === 'components' ? 'Copied' : 'Copy'}
</button>
</div>
</details>
</div>
{/* Install */}
<div className="lm-panel">
<h3>Install a licence key</h3>
<textarea
className="lm-blob"
value={blob}
onChange={e => setBlob(e.target.value)}
placeholder="Paste the licence key from your supplier"
rows={5}
/>
<button className="lm-install" onClick={handleInstall} disabled={saving || !blob.trim()}>
{saving ? 'Checking...' : 'Install licence'}
</button>
</div>
{/* Connected stations */}
{status.slots && (
<div className="lm-panel">
<h3>Connected stations ({status.slots.length})</h3>
{status.slots.length === 0 ? (
<div className="lm-empty">No stations have reported in.</div>
) : (
<table className="lm-table">
<thead>
<tr>
<th>Station</th><th>COM port</th><th>Bridge version</th><th>Last seen</th>
</tr>
</thead>
<tbody>
{status.slots.map(s => (
<tr key={s.station_key}>
<td>{s.station_key}</td>
<td>{s.com_port || '—'}</td>
<td>{s.bridge_version || '—'}</td>
<td>{new Date(s.last_seen).toLocaleTimeString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+577 -131
View File
@@ -1,27 +1,43 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData'; import useSerialData from '../hooks/useSerialData';
import useStagedList, { prefersReducedMotion } from '../hooks/useStagedList';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatures } from '../contexts/NomenclatureContext'; import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext'; import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import { useScaleProtocols } from '../contexts/ScaleProtocolContext';
import { useLicense } from '../contexts/LicenseContext';
import { parseReading } from '../services/scaleProtocols';
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown'; import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
import ReportPreviewModal from './ReportPreviewModal'; import ReportPreviewModal from './ReportPreviewModal';
import ExtraCombinationsTable from './ExtraCombinationsTable';
import { LIST_STAGING, listStagingStyle, PendingMarker, RevealPlate, measureOpeningRow, shouldHoldList } from './VehicleListStaging';
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
import { buildVehicleReportData, vehicleNetWeight } from '../services/vehicleReportData';
import api from '../services/api'; import api from '../services/api';
import './Main.css'; import './Main.css';
import './License/LicenseManager.css';
const getVehicleKey = v => v.id;
export default function Main() { export default function Main() {
const navigate = useNavigate();
const { readings, isConnected } = useSerialData(); const { readings, isConnected } = useSerialData();
const { vehicles } = useNomenclatures(); const { vehicles } = useNomenclatures();
const { currentUser } = useAuth();
const { selectedProtocol } = useScaleProtocols();
const { allowed: licenseAllowed, detail: licenseDetail, stationKey } = useLicense();
const [selectedVehicleId, setSelectedVehicleId] = useState(null); const [selectedVehicleId, setSelectedVehicleId] = useState(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showNewForm, setShowNewForm] = useState(false); const [showNewForm, setShowNewForm] = useState(false);
const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
const [newExtraData, setNewExtraData] = useState({}); const [newExtraData, setNewExtraData] = useState({});
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false); const [showNoticeReport, setShowNoticeReport] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [isOneWeighing, setIsOneWeighing] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
// Nomenclature data from context // Nomenclature data from context
const { definitions, entries } = useNomenclatureData(); const { definitions, entries } = useNomenclatureData();
@@ -33,19 +49,116 @@ export default function Main() {
); );
const [extraData, setExtraData] = useState({}); const [extraData, setExtraData] = useState({});
// Current reading from COM port // A selector's value decides which other nomenclatures appear, so the visible
// Data format: "Value: 36106.04" or just "36106.04" // set is derived per panel from that panel's own extra data.
const currentReading = readings.length > 0 ? readings[0].data : null; const visibleForSelected = useMemo(
const currentWeight = useMemo(() => { () => visibleNomenclatures(vehicleNomenclatures, definitions, extraData),
if (!currentReading) return null; [vehicleNomenclatures, definitions, extraData]
// Extract numeric value - handle "Value: 123.45" or just "123.45" );
const match = currentReading.match(/[\d.]+/); const visibleForNew = useMemo(
if (match) { () => visibleNomenclatures(vehicleNomenclatures, definitions, newExtraData),
const val = parseFloat(match[0]); [vehicleNomenclatures, definitions, newExtraData]
return isNaN(val) ? null : Math.round(val); );
// Switching a selector clears whatever its PREVIOUS choice revealed, so a
// vehicle never carries data contradicting its own selector. Clearing on the
// previous value (rather than diffing visible sets) keeps a nomenclature that
// both choices reveal from being needlessly emptied.
const applySelectorChange = (setter) => (code, newValue) => {
setter(prev => {
const next = { ...prev, [code]: newValue };
if (definitions[code]?.kind === 'selector') {
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
delete next[revealed];
} }
return null; }
}, [currentReading]); if (newValue === undefined) delete next[code];
return next;
});
};
const handleExtraChange = applySelectorChange(setExtraData);
const handleNewExtraChange = applySelectorChange(setNewExtraData);
// Never send a value the selector state hides - the serializer rejects those,
// and stale keys can survive on vehicles that predate the selector.
const prunedExtra = (data, visible) => {
const allowed = new Set(visible.map(n => n.code));
return Object.fromEntries(
Object.entries(data).filter(([code]) => allowed.has(code))
);
};
// Current reading from the serial bridge. The bridge is pure transport - how
// to read a line comes from the station protocol definition, so a new scale
// type never requires updating the bridge on the station PC.
const latestReading = readings.length > 0 ? readings[0] : null;
const parsed = useMemo(
() => parseReading(latestReading, selectedProtocol),
[latestReading, selectedProtocol]
);
const currentWeight = parsed.weight;
const isBalanced = parsed.balanced;
const readingError = parsed.error;
// Equalizer bar history: last 40 readings, oldest first, newest at the
// right. A ticking interval, not a useEffect keyed on value changes - a
// static weight (scale settled, or nothing on it) would otherwise never
// push a new bar at all, since the effect only reruns when the value
// itself changes. Sampling on a timer keeps the strip moving continuously
// whether or not the reading is currently changing.
const EQUALIZER_BAR_COUNT = 40;
const EQUALIZER_SAMPLE_INTERVAL_MS = 250;
const EQUALIZER_FIXED_MAX_KG = 60000;
const [equalizerBars, setEqualizerBars] = useState([]);
// 'fixed' makes bar height comparable across vehicles/sessions; 'auto'
// scales to whatever is on screen right now, so a light load still fills
// the card. Not persisted - always starts on 'fixed' per session.
const [equalizerScaleMode, setEqualizerScaleMode] = useState('fixed');
const latestReadingRef = useRef({ weight: null, balanced: null });
latestReadingRef.current = { weight: currentWeight, balanced: isBalanced };
useEffect(() => {
const id = setInterval(() => {
const { weight, balanced } = latestReadingRef.current;
if (weight === null || isNaN(weight)) return;
setEqualizerBars(prev => [...prev, { value: weight, balanced }].slice(-EQUALIZER_BAR_COUNT));
}, EQUALIZER_SAMPLE_INTERVAL_MS);
return () => clearInterval(id);
}, []);
// Padded to a fixed 40 slots (empty ones render as zero-height bars).
// 'fixed' scales against EQUALIZER_FIXED_MAX_KG (clamped at 100% above
// it); 'auto' scales against the tallest bar currently visible, so the
// row re-scales as the window slides instead of clipping.
const equalizerSlots = useMemo(() => {
const maxValue = equalizerScaleMode === 'fixed'
? EQUALIZER_FIXED_MAX_KG
: Math.max(1, ...equalizerBars.map(b => Math.abs(b.value)));
const padding = Array(Math.max(0, EQUALIZER_BAR_COUNT - equalizerBars.length)).fill(null);
return [...padding, ...equalizerBars].map(bar => ({
balanced: bar?.balanced ?? null,
heightPct: bar ? Math.min(100, Math.max(6, (Math.abs(bar.value) / maxValue) * 100)) : 0,
}));
}, [equalizerBars, equalizerScaleMode]);
// Tell the bridge how to talk to this scale. Polled indicators stay silent
// until asked, so the bridge must send the command on its own timer - a
// browser tab cannot be the heartbeat (background tabs get throttled, and a
// closed tab would stop the scale). The UI only supplies the string.
useEffect(() => {
if (!selectedProtocol) return;
const serialUrl = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
fetch(serialUrl + '/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
command: selectedProtocol.poll_command || '',
interval: selectedProtocol.poll_interval || 0.2,
}),
}).catch(() => {
// Bridge not reachable - the SSE connection state already shows this.
});
}, [selectedProtocol]);
// Selected vehicle object (refreshes when vehicles list updates via SSE) // Selected vehicle object (refreshes when vehicles list updates via SSE)
const selectedVehicle = useMemo( const selectedVehicle = useMemo(
@@ -53,12 +166,60 @@ export default function Main() {
[vehicles, selectedVehicleId] [vehicles, selectedVehicleId]
); );
// Filtered vehicles // While the mouse is over the list, staged changes wait (useStagedList
// `hold`). Only mouse/pen hold, so a touch screen never gets stuck. A
// click on a row releases; a mouse move REHOLD_PX away from the click
// point holds again (shouldHoldList).
const [isListHeld, setIsListHeld] = useState(false);
const lastPointerRef = useRef(null);
const holdReleasedAtRef = useRef(null);
const trackListPointer = e => {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') {
lastPointerRef.current = { x: e.clientX, y: e.clientY };
}
};
const handleListPointerOver = e => {
trackListPointer(e);
if (!shouldHoldList(e, holdReleasedAtRef.current)) return;
holdReleasedAtRef.current = null;
if (!isListHeld) setIsListHeld(true);
};
const handleListPointerLeave = () => {
holdReleasedAtRef.current = null;
setIsListHeld(false);
};
const releaseListHoldOnClick = () => {
holdReleasedAtRef.current = lastPointerRef.current;
setIsListHeld(false);
};
useEffect(() => {
const release = () => setIsListHeld(false);
const handleVisibilityChange = () => { if (document.hidden) release(); };
window.addEventListener('blur', release);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('blur', release);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// The list lags the live data so rows never shift under the cursor
// unannounced; the search filter applies to it instantly.
const stagedVehicles = useStagedList(vehicles, {
getKey: getVehicleKey,
delay: LIST_STAGING.delay,
distortMs: LIST_STAGING.distortMs,
enterMs: LIST_STAGING.enterMs,
animationMs: LIST_STAGING.exitMs,
hold: isListHeld,
releaseStagger: LIST_STAGING.releaseStagger,
});
const filteredVehicles = useMemo(() => { const filteredVehicles = useMemo(() => {
if (!searchQuery.trim()) return vehicles; if (!searchQuery.trim()) return stagedVehicles;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q)); return stagedVehicles.filter(({ item }) => item.vehicle_number.toLowerCase().includes(q));
}, [vehicles, searchQuery]); }, [stagedVehicles, searchQuery]);
const reduceMotion = prefersReducedMotion();
// When vehicle selection changes, load its extra data // When vehicle selection changes, load its extra data
useEffect(() => { useEffect(() => {
@@ -73,7 +234,7 @@ export default function Main() {
const handleNewVehicle = () => { const handleNewVehicle = () => {
setSelectedVehicleId(null); setSelectedVehicleId(null);
setShowNewForm(true); setShowNewForm(true);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({}); setNewExtraData({});
setError(''); setError('');
}; };
@@ -81,7 +242,7 @@ export default function Main() {
// Cancel new vehicle form // Cancel new vehicle form
const handleCancelNew = () => { const handleCancelNew = () => {
setShowNewForm(false); setShowNewForm(false);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({}); setNewExtraData({});
setError(''); setError('');
}; };
@@ -97,16 +258,18 @@ export default function Main() {
trailer1_number: newForm.trailer1_number.trim() || null, trailer1_number: newForm.trailer1_number.trim() || null,
trailer2_number: newForm.trailer2_number.trim() || null, trailer2_number: newForm.trailer2_number.trim() || null,
driver_pid: newForm.driver_pid.trim() || null, driver_pid: newForm.driver_pid.trim() || null,
driver_name: newForm.driver_name.trim(),
}; };
// Include extra data if any values set // Include extra data if any values set
const hasExtra = Object.values(newExtraData).some(v => v !== undefined && v !== null && v !== ''); const visibleNewExtra = prunedExtra(newExtraData, visibleForNew);
const hasExtra = Object.values(visibleNewExtra).some(v => v !== undefined && v !== null && v !== '');
if (hasExtra) { if (hasExtra) {
payload.extra = { data: newExtraData }; payload.extra = { data: visibleNewExtra };
} }
const res = await api.post('/api/vehicles/', payload); const res = await api.post('/api/vehicles/', payload);
setSelectedVehicleId(res.data.id); setSelectedVehicleId(res.data.id);
setShowNewForm(false); setShowNewForm(false);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({}); setNewExtraData({});
} catch (err) { } catch (err) {
const data = err.response?.data; const data = err.response?.data;
@@ -117,25 +280,65 @@ export default function Main() {
} }
}; };
// Set tare // Record a weighing - the backend decides whether it is tare or gross
const handleSetTare = async () => { const handleMeasure = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return; if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError(''); setError('');
try { try {
await api.post(`/api/vehicles/${selectedVehicle.id}/set-tare/`, { value: currentWeight }); await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
} catch { value: currentWeight,
setError('Failed to set tare'); station_key: stationKey,
});
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
} }
}; };
// Set gross // Finalize on the single recorded weight (external clients weighed once).
const handleSetGross = async () => { // Like handleMeasure, the screen updates from the server's SSE broadcast.
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return; const handleOneWeight = async () => {
if (!selectedVehicle || isOneWeighing) return;
if (!window.confirm('Finalize with a single weight?')) return;
setIsOneWeighing(true);
setError(''); setError('');
try { try {
await api.post(`/api/vehicles/${selectedVehicle.id}/set-gross/`, { value: currentWeight }); await api.post(`/api/vehicles/${selectedVehicle.id}/one-weight/`, {
} catch { station_key: stationKey,
setError('Failed to set gross'); });
} catch (err) {
setError(err.response?.data?.detail || 'Failed to finalize with a single weight');
} finally {
setIsOneWeighing(false);
}
};
// Record a weighing entered by hand - requires a note
const handleManualMeasure = async () => {
if (!selectedVehicle) return;
const value = parseInt(manualForm.value, 10);
if (isNaN(value)) {
setError('Enter a valid weight');
return;
}
if (!manualForm.note.trim()) {
setError('A note is required for a manual measurement');
return;
}
setIsSaving(true);
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
value,
manual: true,
note: manualForm.note.trim(),
station_key: stationKey,
});
setShowManualForm(false);
setManualForm({ value: '', note: '' });
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
} finally {
setIsSaving(false);
} }
}; };
@@ -146,7 +349,7 @@ export default function Main() {
setError(''); setError('');
try { try {
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, { await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
extra: { data: extraData }, extra: { data: prunedExtra(extraData, visibleForSelected) },
}); });
} catch (err) { } catch (err) {
const data = err.response?.data; const data = err.response?.data;
@@ -156,6 +359,26 @@ export default function Main() {
} }
}; };
// Apply a saved combination - merge so non-lookup field values are preserved
// A combination carries its selector value, so applying one must also drop
// whatever the previous selection revealed - otherwise a stale field survives
// the merge and the payload contradicts the new source.
const applyCombination = (setter) => (data) => {
setter(prev => {
const next = { ...prev, ...data };
for (const [code, nom] of Object.entries(definitions)) {
if (nom.kind !== 'selector') continue;
if (!(code in data) || data[code] === prev[code]) continue;
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
if (!(revealed in data)) delete next[revealed];
}
}
return next;
});
};
const handleApplyCombination = applyCombination(setExtraData);
const handleApplyCombinationNew = applyCombination(setNewExtraData);
// Format date // Format date
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
if (!dateStr) return ''; if (!dateStr) return '';
@@ -163,39 +386,69 @@ export default function Main() {
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}; };
// Net weight // Net weight - the server's net when set (a one-weight vehicle has no tare,
const netWeight = (selectedVehicle?.tare != null && selectedVehicle?.gross != null) // so gross - tare cannot produce it), otherwise derived from both weighings.
? selectedVehicle.gross - selectedVehicle.tare const netWeight = vehicleNetWeight(selectedVehicle);
: null;
// Vehicle data shaped for report preview/design const canMeasure = !!currentUser?.can_measure;
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value } // doc_number is assigned on finalization by both the second weighing and
const vehicleReportData = useMemo(() => { // the one-weight path, so it is the one signal that covers both.
if (!selectedVehicle) return null; const isFinalized = selectedVehicle?.doc_number != null;
const rawExtra = selectedVehicle.extra?.data || {};
const extra = {}; // Weighed exactly once and still open - may be closed on that single weight.
for (const [code, value] of Object.entries(rawExtra)) { const canOneWeight = canMeasure && !isFinalized && !selectedVehicle?.is_void
const def = definitions[code]; && (selectedVehicle?.tare != null) !== (selectedVehicle?.gross != null);
if (!def) continue;
if (def.kind === 'lookup') { // Vehicle data shaped for report preview/design (shared with History)
const entry = (entries[code] || []).find(e => e.id === value); const vehicleReportData = useMemo(
extra[def.name] = entry?.display_value ?? ''; () => buildVehicleReportData(selectedVehicle, definitions, entries),
} else { [selectedVehicle, definitions, entries]
extra[def.name] = value ?? ''; );
// Look up the saved notice report by name. Server-side filter — ?name= is a
// real filter now that django-filter is configured, replacing the old
// fetch-every-report-then-.find() pattern.
const fetchNoticeReport = async () => {
const res = await api.get('/api/reports/?name=notice_report');
return (res.data.results || res.data || [])[0] || null;
};
// Warm the A4 print chunk on hover/focus, so the click handler's dynamic
// import resolves from cache instead of blocking on a network round-trip
// between the operator's click and window.print(). Cheap insurance: the
// print dialog is not gated behind transient user activation the way
// window.open is, but the shorter the gap the less there is to go wrong on
// a slow station. Failure is ignored — the click path imports it anyway.
const prefetchA4Print = () => {
import('./ReportEditor/a4/print/printReport').catch(() => {});
};
// Print Notice always opens the unified preview modal now — the modal
// itself fetches both the matrix and a4 notice_report variants and decides
// what Print/Download do based on whichever kind is selected in its own
// switcher. Previously an a4 report skipped the modal and went straight to
// window.print(); that bypass is gone so both kinds get the same
// preview/print/download/mark-notice-printed flow.
const handlePrintNotice = async () => {
if (isPrinting) return;
setIsPrinting(true);
try {
const report = await fetchNoticeReport();
if (!report) {
setError('Report "notice_report" not found. Create it in the Report Editor first.');
return;
} }
} setShowNoticeReport(true);
return { } catch (err) {
vehicle: { console.error('Failed to print notice:', err);
...selectedVehicle, setError('Failed to load the notice report');
net: netWeight, } finally {
extra, setIsPrinting(false);
} }
}; };
}, [selectedVehicle, netWeight, definitions, entries]);
return ( return (
<div> <div className="main-page">
<Header />
<div className="main"> <div className="main">
{/* Left panel - Vehicle list */} {/* Left panel - Vehicle list */}
<div className="main-left"> <div className="main-left">
@@ -215,20 +468,46 @@ export default function Main() {
/> />
</div> </div>
<div className="vehicle-list">
{filteredVehicles.map(v => (
<div <div
key={v.id} className="vehicle-list"
className={`vehicle-list-item ${selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''}`} style={listStagingStyle}
onClick={() => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); }} onPointerEnter={handleListPointerOver}
onPointerDown={trackListPointer}
onPointerMove={handleListPointerOver}
onPointerLeave={handleListPointerLeave}
> >
<span className="vehicle-list-number">{v.vehicle_number}</span> {filteredVehicles.map(({ id, item: v, phase }) => {
if (phase === 'pending' || phase === 'distorting') {
return <PendingMarker key={id} reduceMotion={reduceMotion} distorting={phase === 'distorting'} />;
}
const isGone = phase === 'leaving' || phase === 'exiting';
const isEntering = phase === 'entering';
// Only settled rows are clickable: an opening row is
// still changing size under the cursor.
const isInteractive = phase === 'stable';
const stateClass = {
entering: 'vehicle-list-item--entering',
leaving: 'vehicle-list-item--leaving',
exiting: 'vehicle-list-item--leaving vehicle-list-item--exiting',
}[phase] || '';
return (
<div
key={id}
className={`vehicle-list-item ${!isGone && selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''} ${stateClass}`}
ref={isEntering ? measureOpeningRow : undefined}
aria-disabled={!isInteractive || undefined}
onClick={isInteractive ? () => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); releaseListHoldOnClick(); } : undefined}
>
{isEntering && !reduceMotion
? <RevealPlate text={v.vehicle_number} />
: <span className="vehicle-list-number">{v.vehicle_number}</span>}
{v.tare != null && ( {v.tare != null && (
<span className="vehicle-list-weight">{v.tare} kg</span> <span className="vehicle-list-weight">{v.tare} kg</span>
)} )}
</div> </div>
))} );
{filteredVehicles.length === 0 && ( })}
{!filteredVehicles.some(e => e.phase !== 'pending' && e.phase !== 'distorting') && (
<div className="vehicle-list-empty">No vehicles found</div> <div className="vehicle-list-empty">No vehicles found</div>
)} )}
</div> </div>
@@ -243,7 +522,7 @@ export default function Main() {
<h2 className="vehicle-detail-title">New Vehicle</h2> <h2 className="vehicle-detail-title">New Vehicle</h2>
<div className="vehicle-form-section"> <div className="vehicle-form-section">
<div className="extra-fields"> <div className="vehicle-new-fields">
<div className="extra-field"> <div className="extra-field">
<label>Vehicle Number *</label> <label>Vehicle Number *</label>
<input <input
@@ -281,22 +560,32 @@ export default function Main() {
disabled={isSaving} disabled={isSaving}
/> />
</div> </div>
<div className="extra-field">
<label>Driver Name *</label>
<input
type="text"
value={newForm.driver_name}
onChange={e => setNewForm(prev => ({ ...prev, driver_name: e.target.value }))}
disabled={isSaving}
/>
</div>
</div> </div>
</div> </div>
{vehicleNomenclatures.length > 0 && ( {vehicleNomenclatures.length > 0 && (
<div className="extra-section"> <div className="extra-section">
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombinationNew}
/>
<h3>Additional Data</h3> <h3>Additional Data</h3>
<div className="extra-fields"> <div className="extra-fields">
{vehicleNomenclatures.map(nom => ( {visibleForNew.map(nom => (
<div key={nom.code} className="extra-field"> <div key={nom.code} className="extra-field">
<NomenclatureDropdown <NomenclatureDropdown
nomenclatureCode={nom.code} nomenclatureCode={nom.code}
value={newExtraData[nom.code]} value={newExtraData[nom.code]}
onChange={val => setNewExtraData(prev => ({ onChange={val => handleNewExtraChange(nom.code, val)}
...prev,
[nom.code]: val,
}))}
disabled={isSaving} disabled={isSaving}
/> />
</div> </div>
@@ -316,7 +605,7 @@ export default function Main() {
<button <button
className="extra-save-btn" className="extra-save-btn"
onClick={handleCreateVehicle} onClick={handleCreateVehicle}
disabled={isSaving || !newForm.vehicle_number.trim()} disabled={isSaving || !newForm.vehicle_number.trim() || !newForm.driver_name.trim()}
> >
{isSaving ? 'Creating...' : 'Create Vehicle'} {isSaving ? 'Creating...' : 'Create Vehicle'}
</button> </button>
@@ -330,103 +619,218 @@ export default function Main() {
<div className="vehicle-detail"> <div className="vehicle-detail">
{error && <div className="vehicle-error">{error}</div>} {error && <div className="vehicle-error">{error}</div>}
{/* Header bar: icon, plate number, derived status pills, and (cockpit
only) a photo. Both pills come from real state, not decoration:
"on scale" is this vehicle having a live reading right now, and
the weighing status is the same isFinalized used by the cards
below. */}
<div className="vehicle-hero">
<div className="vehicle-heading">
<span className="vehicle-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h11v11H3z" />
<path d="M14 10h4l3 3v4h-7z" />
<circle cx="7" cy="19" r="2" />
<circle cx="18" cy="19" r="2" />
</svg>
</span>
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2> <h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
<span className={`vehicle-status ${isFinalized ? 'vehicle-status--done' : 'vehicle-status--progress'}`}>
● {isFinalized ? 'Finalized' : 'In progress'}
</span>
{isConnected && currentWeight !== null && !isNaN(currentWeight) && (
<span className="vehicle-status vehicle-status--scale">◉ On Scale</span>
)}
</div>
{/* Current reading */} {/* Equalizer: last 40 live readings, oldest to newest left to
<div className="reading-display"> right. Green when the last reading was stable, rose when it
<label>Current Reading</label> wasn't. The corner button switches between scaling against a
<div className={`reading-value ${isConnected ? '' : 'reading-value--disconnected'}`}> fixed 60t ceiling (comparable across vehicles) and scaling to
whatever is on screen right now (fills the strip for any
load). No border/background of its own - it sits directly on
the hero bar between the pills and the photo. */}
<div className="vehicle-hero-equalizer">
<button
type="button"
className="equalizer-scale-toggle"
onClick={() => setEqualizerScaleMode(m => m === 'fixed' ? 'auto' : 'fixed')}
title={equalizerScaleMode === 'fixed'
? 'Scaled to 60,000 kg - click for auto-scale'
: 'Auto-scaled to visible readings - click for fixed 60,000 kg'}
>
{equalizerScaleMode === 'fixed' ? '60t' : 'AUTO'}
</button>
<div className="equalizer-bars">
{equalizerSlots.map((slot, i) => (
<span
key={i}
className={`equalizer-bar ${
slot.balanced === null ? '' : slot.balanced ? 'equalizer-bar--balanced' : 'equalizer-bar--unbalanced'
}`}
style={{ height: `${slot.heightPct}%` }}
/>
))}
</div>
</div>
<div className="vehicle-hero-photo" aria-hidden="true" />
</div>
{/* Weighing section - four readouts plus an action column, all of
which always render, so the row never changes its column count
as a weighing progresses. The readouts hold no controls: every
button lives in the action column. */}
<div className="weighing-section">
{/* Current reading, which doubles as the Measure card:
both showed the same live weight. Icon and text sit in
separate columns (weight-card-icon / -content), matching
the reference layout - the icon spans the card's full
height instead of sitting inline with the label. */}
<div className="weight-card weight-card--reading">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Current Reading</div>
<div className={`weight-card-value weight-card-value--reading ${
!isConnected
? 'reading-value--disconnected'
: currentWeight === null || isNaN(currentWeight)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{currentWeight !== null && !isNaN(currentWeight) {currentWeight !== null && !isNaN(currentWeight)
? `${currentWeight} kg` ? `${currentWeight} kg`
: (isConnected ? 'Waiting...' : 'Disconnected') : (isConnected ? 'Waiting...' : 'Disconnected')
} }
</div> </div>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
</div>
)}
</div>
{/* Measure/Manual entry, moved here from the action column:
they act on THIS card's live value, so they sit beside it. */}
{canMeasure && !isFinalized && (
<div className="weight-card-reading-actions">
<button
className="weight-set-btn"
onClick={handleMeasure}
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
title={licenseAllowed ? undefined : licenseDetail}
>
Measure
</button>
{!licenseAllowed && (
<div className="weight-license-block">{licenseDetail}</div>
)}
{currentUser?.can_manually_measure && (
<button
className="weight-manual-btn"
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
>
Manual entry
</button>
)}
</div>
)}
</div> </div>
{/* Weighing section */} {/* Tare. A lone reading is parked here by the backend, but a
<div className="weighing-section"> lighter second one moves it to Gross - the slot is shown
{/* Tare */} plainly rather than hedged behind a neutral label. */}
<div className="weight-card"> <div className="weight-card weight-card--tare">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Tare</div> <div className="weight-card-header">Tare</div>
<div className="weight-card-value"> <div className="weight-card-value">
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'} {selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '—'}
</div> </div>
{selectedVehicle.tare_date && ( {selectedVehicle.tare != null && (
<div className="weight-card-meta"> <div className="weight-card-meta">
{formatDate(selectedVehicle.tare_date)} {formatDate(selectedVehicle.tare_date)}
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`} {selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
{selectedVehicle.tare_manual && <span className="weight-manual-tag">manual</span>}
</div> </div>
)} )}
<button </div>
className="weight-set-btn"
onClick={handleSetTare}
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.tare != null}
>
Set Tare
</button>
</div> </div>
{/* Gross */} {/* Gross */}
<div className="weight-card"> <div className="weight-card weight-card--gross">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Gross</div> <div className="weight-card-header">Gross</div>
<div className="weight-card-value"> <div className="weight-card-value">
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'} {selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '—'}
</div> </div>
{selectedVehicle.gross_date && ( {selectedVehicle.gross != null && (
<div className="weight-card-meta"> <div className="weight-card-meta">
{formatDate(selectedVehicle.gross_date)} {formatDate(selectedVehicle.gross_date)}
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`} {selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
{selectedVehicle.gross_manual && <span className="weight-manual-tag">manual</span>}
</div> </div>
)} )}
<button </div>
className="weight-set-btn"
onClick={handleSetGross}
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.gross != null}
>
Set Gross
</button>
</div> </div>
{/* Net */} {/* Net */}
{netWeight !== null && (
<div className="weight-card weight-card--net"> <div className="weight-card weight-card--net">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Net</div> <div className="weight-card-header">Net</div>
<div className="weight-card-value">{netWeight} kg</div> <div className="weight-card-value">
<button {netWeight !== null ? `${netWeight} kg` : '—'}
className="weight-set-btn"
onClick={() => setShowNoticeReport(true)}
>
Print Notice
</button>
<button
className="weight-set-btn"
onClick={() => navigate('/report-editor', {
state: {
reportName: 'notice_report',
vehicleData: vehicleReportData,
}
})}
>
Design Notice
</button>
</div> </div>
</div>
</div>
{/* Action column. Measure/Manual entry live in the Current
Reading card, since they act on that card's live value. Here:
One weight only after a single weighing, Print Notice once
finalized; otherwise empty (just its panel chrome). */}
<div className="weight-card weight-actions">
{canOneWeight && (
<button
className="weight-set-btn"
onClick={handleOneWeight}
disabled={isOneWeighing || !licenseAllowed}
title={licenseAllowed ? undefined : licenseDetail}
>
{isOneWeighing ? 'Finalizing...' : 'One weight only'}
</button>
)} )}
{isFinalized && netWeight !== null && (
<button
className="weight-set-btn"
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
{isPrinting ? 'Preparing...' : 'Print Notice'}
</button>
)}
</div>
</div> </div>
{/* Extra fields from nomenclatures */} {/* Extra fields from nomenclatures */}
{vehicleNomenclatures.length > 0 && ( {vehicleNomenclatures.length > 0 && (
<div className="extra-section"> <div className="extra-section">
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<h3>Additional Data</h3> <h3>Additional Data</h3>
<div className="extra-fields"> <div className="extra-fields">
{vehicleNomenclatures.map(nom => ( {visibleForSelected.map(nom => (
<div key={nom.code} className="extra-field"> <div key={nom.code} className="extra-field">
<NomenclatureDropdown <NomenclatureDropdown
nomenclatureCode={nom.code} nomenclatureCode={nom.code}
value={extraData[nom.code]} value={extraData[nom.code]}
onChange={val => setExtraData(prev => ({ onChange={val => handleExtraChange(nom.code, val)}
...prev,
[nom.code]: val,
}))}
/> />
</div> </div>
))} ))}
@@ -450,6 +854,48 @@ export default function Main() {
onClose={() => setShowNoticeReport(false)} onClose={() => setShowNoticeReport(false)}
/> />
)} )}
{showManualForm && (
<div className="manual-overlay" onClick={() => setShowManualForm(false)}>
<div className="manual-content" onClick={e => e.stopPropagation()}>
<h3>Manual measurement</h3>
<div className="manual-field">
<label>Weight (kg)</label>
<input
type="number"
value={manualForm.value}
onChange={e => setManualForm(prev => ({ ...prev, value: e.target.value }))}
autoFocus
/>
</div>
<div className="manual-field">
<label>Reason</label>
<input
type="text"
value={manualForm.note}
onChange={e => setManualForm(prev => ({ ...prev, note: e.target.value }))}
placeholder="Why is this entered by hand?"
/>
</div>
<div className="manual-actions">
<button
className="vehicle-form-cancel"
onClick={() => setShowManualForm(false)}
disabled={isSaving}
>
Cancel
</button>
<button
className="weight-set-btn"
onClick={handleManualMeasure}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Record'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+428
View File
@@ -0,0 +1,428 @@
/* App shell: the page content takes everything left of the rail and exactly
the viewport height. The content column scrolls on its own, so no screen can
produce a page (body) scrollbar; a screen that must fit without scrolling
(Main) sizes itself to this column with flex instead of calc(100vh - x). */
.app-shell {
display: flex;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.app-shell-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
/* The rail is the old header turned on its side, so it keeps the header's
surface tokens. */
.nav-rail {
--rail-width: 68px;
position: relative;
z-index: 100;
width: var(--rail-width);
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) 0;
background: var(--header-bg);
color: var(--header-text);
box-shadow: var(--shadow-raised);
}
.nav-rail-top,
.nav-rail-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.nav-rail-bottom {
gap: var(--space-2-5);
}
/* Positioning context for a flyout opening beside its trigger. */
.nav-rail-anchor {
position: relative;
display: flex;
}
.nav-rail-item {
width: 60px;
padding: var(--space-1-5) var(--space-0-5) var(--space-1);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-0-5);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--header-text);
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.nav-rail-item:hover {
background: var(--header-chrome);
border-color: var(--header-chrome-border);
}
.nav-rail-icon {
font-size: var(--fs-xl);
line-height: 1.2;
}
.nav-rail-label {
max-width: 100%;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The lit edge faces the content, the way a tab points at its page. */
.nav-rail-item--active,
.nav-rail-item--active:hover {
background: var(--header-chrome-hover);
border-color: var(--header-chrome-border);
box-shadow: inset 3px 0 0 var(--header-active);
}
.nav-rail-item:focus-visible,
.nav-rail-theme:focus-visible,
.nav-rail-avatar:focus-visible {
outline: 2px solid var(--header-active);
outline-offset: 2px;
}
.nav-rail-theme {
width: 38px;
height: 38px;
border-radius: var(--radius-sm);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
font-size: var(--fs-xl);
line-height: 1;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-rail-theme:hover {
background: var(--header-chrome-hover);
transform: scale(1.05);
}
.nav-rail-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
padding: 0;
background: var(--header-avatar-bg);
color: var(--header-avatar-text);
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
font-weight: var(--fw-bold);
font-size: var(--fs-lg);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-card);
}
.nav-rail-avatar:hover {
transform: scale(1.05);
box-shadow: var(--shadow-raised);
}
/* Over the page, under the flyout: any outside click closes it. */
.nav-rail-backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
/* Flyouts open to the LEFT of the rail, over the page content. */
.nav-rail-flyout {
position: absolute;
right: calc(100% + var(--space-2-5));
z-index: 2;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
.nav-rail-menu {
top: 0;
min-width: 220px;
}
/* The avatar sits at the foot of the rail, so its menu grows upwards. */
.nav-rail-user-menu {
bottom: 0;
min-width: 180px;
}
.nav-rail-flyout button {
display: block;
width: 100%;
padding: var(--space-2-5) var(--space-4);
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: inherit;
font-size: var(--fs-md);
}
.nav-rail-flyout button + button,
.nav-rail-user + button {
border-top: 1px solid var(--border);
}
.nav-rail-flyout button:hover,
.nav-rail-flyout button:focus-visible {
background: var(--surface-hover);
outline: none;
}
.nav-rail-menu strong,
.nav-rail-user strong {
display: block;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
}
.nav-rail-menu span,
.nav-rail-user span {
display: block;
margin-top: var(--space-0-5);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.nav-rail-user {
padding: var(--space-2-5) var(--space-4);
color: var(--text);
background: var(--surface-sunken);
}
/* Licence warning: amber while in grace, red once measuring is blocked. The
colour drives the label, the "!" marker and the badge; an emoji icon keeps
its own colours, so the item also gets a tinted surface. */
.nav-rail-item--license-grace {
--license-tone: var(--warning);
--license-tone-soft: var(--warning-soft);
}
.nav-rail-item--license-blocked {
--license-tone: var(--danger);
--license-tone-soft: var(--danger-soft);
}
.nav-rail-item--license {
color: var(--license-tone);
background: var(--license-tone-soft);
}
.nav-rail-item--license:hover {
background: var(--license-tone-soft);
border-color: var(--license-tone);
}
.nav-rail-item--license .nav-rail-icon {
position: relative;
font-weight: var(--fw-bold);
}
.nav-rail-badge {
position: absolute;
top: 0;
right: -6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--license-tone);
box-shadow: 0 0 0 2px var(--header-bg);
}
/* One gentle pulse when the badge first appears, never on a loop. */
@media (prefers-reduced-motion: no-preference) {
.nav-rail-badge {
animation: nav-rail-badge-pulse 1.2s ease-out 1;
}
}
@keyframes nav-rail-badge-pulse {
0% { transform: scale(1); }
35% { transform: scale(1.6); }
100% { transform: scale(1); }
}
.nav-rail-license-popover {
top: 0;
width: 260px;
border-left: 3px solid var(--warning);
}
.nav-rail-license-popover--blocked {
border-left-color: var(--danger);
}
/* The non-admin marker sits near the foot of the rail: grow upwards. */
.nav-rail-license-popover--bottom {
top: auto;
bottom: 0;
}
.nav-rail-license-popover p {
margin: 0;
padding: var(--space-2-5) var(--space-4);
color: var(--text);
font-size: var(--fs-md);
line-height: 1.4;
}
.nav-rail-license-popover strong {
font-weight: var(--fw-semibold);
}
.nav-rail-license-popover button {
border-top: 1px solid var(--border);
font-weight: var(--fw-semibold);
}
/* Cockpit: the rail needs its own lit edge facing the content, or it floats,
and the active item glows like the rest of the lit instruments. */
[data-theme="cockpit"] .nav-rail {
border-left: 1px solid var(--border);
box-shadow: -1px 0 0 color-mix(in srgb, var(--accent) 25%, transparent),
var(--shadow-raised);
}
[data-theme="cockpit"] .nav-rail-item--active {
color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent), var(--glow-accent);
}
[data-theme="cockpit"] .nav-rail-flyout {
border-color: var(--border-strong);
}
[data-theme="cockpit"] .nav-rail-license-popover {
border-left-color: var(--warning);
}
[data-theme="cockpit"] .nav-rail-license-popover--blocked {
border-left-color: var(--danger);
}
[data-theme="cockpit"] .nav-rail-item--license-grace .nav-rail-badge {
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-warning);
}
[data-theme="cockpit"] .nav-rail-item--license-blocked .nav-rail-badge {
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-danger);
}
/* Short windows: drop the labels before the rail runs out of height. */
@media (max-height: 620px) and (min-width: 700px) {
.nav-rail-label {
display: none;
}
.nav-rail-item {
padding: var(--space-1-5) var(--space-0-5);
}
}
/* Narrow screens: the rail becomes a bottom bar, icons only. */
@media (max-width: 699px) {
.app-shell {
flex-direction: column;
}
.nav-rail {
width: auto;
flex-direction: row;
align-items: center;
padding: var(--space-1) var(--space-2);
}
[data-theme="cockpit"] .nav-rail {
border-left: none;
border-top: 1px solid var(--border);
}
.nav-rail-top,
.nav-rail-bottom {
flex-direction: row;
gap: var(--space-0-5);
}
.nav-rail-top {
flex: 1;
min-width: 0;
justify-content: space-around;
}
.nav-rail-item {
width: auto;
min-width: 36px;
padding: var(--space-1);
}
.nav-rail-label {
display: none;
}
.nav-rail-item--active,
.nav-rail-item--active:hover,
[data-theme="cockpit"] .nav-rail-item--active {
box-shadow: inset 0 -3px 0 var(--header-active);
}
.nav-rail-theme,
.nav-rail-avatar {
width: 34px;
height: 34px;
font-size: var(--fs-md);
}
/* Above the bar instead of beside it. */
.nav-rail-flyout {
right: auto;
left: 0;
top: auto;
bottom: calc(100% + var(--space-2));
}
.nav-rail-user-menu,
.nav-rail-license-popover {
left: auto;
right: 0;
}
.nav-rail-license-popover {
max-width: calc(100vw - 2 * var(--space-4));
}
}
+355
View File
@@ -0,0 +1,355 @@
import React, { useState, useEffect, useRef, useId } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useLicense } from '../contexts/LicenseContext';
import { getLicenseWarning } from './licenseWarning';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './NavRail.css';
function RailItem({ icon, label, title, onClick, isActive = false, buttonRef, className = '', noTooltip = false, ...rest }) {
return (
<button
ref={buttonRef}
className={`nav-rail-item${isActive ? ' nav-rail-item--active' : ''}${className ? ` ${className}` : ''}`}
onClick={onClick}
title={noTooltip ? undefined : title}
aria-label={title}
aria-current={isActive ? 'page' : undefined}
{...rest}
>
<span className="nav-rail-icon" aria-hidden="true">{icon}</span>
<span className="nav-rail-label" aria-hidden="true">{label}</span>
</button>
);
}
const LICENSE_OPEN_DELAY_MS = 200;
const LICENSE_CLOSE_DELAY_MS = 250;
// Licence item in its warning state, with a popover beside the rail that opens
// on hover (after a short delay), focus or click. Admins get the real Licence
// item, which still navigates; everyone else gets a "!" marker that does not.
function LicenseWarning({ warning, isAdmin, isActive, onOpenPage }) {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef(null);
const triggerRef = useRef(null);
const timerRef = useRef(null);
const skipFocusOpenRef = useRef(false);
const popoverId = useId();
useEffect(() => () => clearTimeout(timerRef.current), []);
useEffect(() => {
if (!isOpen) return undefined;
const handleMouseDown = (e) => {
if (!anchorRef.current?.contains(e.target)) setIsOpen(false);
};
const handleKeyDown = (e) => {
if (e.key !== 'Escape') return;
clearTimeout(timerRef.current);
setIsOpen(false);
// Only pull focus back when it was inside the popover itself.
const active = document.activeElement;
if (active !== triggerRef.current && anchorRef.current?.contains(active)) {
skipFocusOpenRef.current = true;
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const openNow = () => {
clearTimeout(timerRef.current);
setIsOpen(true);
};
// Entering again (e.g. trigger -> popover across the gap) cancels a pending close.
const handleMouseEnter = () => {
clearTimeout(timerRef.current);
if (!isOpen) timerRef.current = setTimeout(() => setIsOpen(true), LICENSE_OPEN_DELAY_MS);
};
const handleMouseLeave = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIsOpen(false), LICENSE_CLOSE_DELAY_MS);
};
const handleFocus = () => {
if (skipFocusOpenRef.current) {
skipFocusOpenRef.current = false;
return;
}
openNow();
};
// A null relatedTarget is a click on something unfocusable; the outside
// mousedown handler covers the case where that was outside the anchor.
const handleBlur = (e) => {
if (e.relatedTarget && !anchorRef.current?.contains(e.relatedTarget)) {
clearTimeout(timerRef.current);
setIsOpen(false);
}
};
const handleOpenPage = () => {
setIsOpen(false);
onOpenPage();
};
const badgeIcon = (icon) => (
<>
{icon}
<span className="nav-rail-badge" />
</>
);
return (
<div
ref={anchorRef}
className="nav-rail-anchor"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
>
<RailItem
buttonRef={triggerRef}
className={`nav-rail-item--license nav-rail-item--license-${warning.level}`}
icon={badgeIcon(isAdmin ? '🔑' : '!')}
label="Licence"
title={`Licence: ${warning.message}`}
noTooltip /* the popover already shows the message; aria-label keeps it for screen readers */
onClick={isAdmin ? onOpenPage : openNow}
onFocus={handleFocus}
isActive={isActive}
aria-describedby={isOpen ? popoverId : undefined}
aria-expanded={isOpen}
aria-controls={isOpen ? popoverId : undefined}
/>
{isOpen && (
<div
id={popoverId}
className={`nav-rail-flyout nav-rail-license-popover nav-rail-license-popover--${warning.level}${isAdmin ? '' : ' nav-rail-license-popover--bottom'}`}
role={isAdmin ? 'dialog' : 'tooltip'}
aria-label={isAdmin ? 'Licence warning' : undefined}
>
<p>
{warning.lead}
{warning.emphasis && <strong>{warning.emphasis}</strong>}
{warning.tail}
</p>
{isAdmin && (
<button onClick={handleOpenPage}>Open licence page</button>
)}
</div>
)}
</div>
);
}
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return (user.username || '').substring(0, 2).toUpperCase();
};
function NavRail() {
const { currentUser, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { status: licenseStatus } = useLicense();
const licenseWarning = getLicenseWarning(licenseStatus);
const navigate = useNavigate();
const { pathname } = useLocation();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const [showEditorMenu, setShowEditorMenu] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const editorButtonRef = useRef(null);
const avatarButtonRef = useRef(null);
// Escape closes whichever flyout is open and hands focus back to its trigger.
useEffect(() => {
if (!showEditorMenu && !showUserMenu) return undefined;
const handleKeyDown = (e) => {
if (e.key !== 'Escape') return;
const trigger = showEditorMenu ? editorButtonRef.current : avatarButtonRef.current;
setShowEditorMenu(false);
setShowUserMenu(false);
trigger?.focus();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showEditorMenu, showUserMenu]);
const openEditor = (route) => {
setShowEditorMenu(false);
navigate(route);
};
const handleChangePassword = () => {
setShowUserMenu(false);
setShowPasswordOverlay(true);
};
const handleLogout = () => {
setShowUserMenu(false);
logout();
};
return (
<>
<nav className="nav-rail" aria-label="Main navigation">
<div className="nav-rail-top">
<RailItem
icon="⚖️"
label="Weighing"
title="Weighing"
onClick={() => navigate('/')}
isActive={pathname === '/'}
/>
{currentUser?.can_edit_documents && (
<div className="nav-rail-anchor">
<RailItem
buttonRef={editorButtonRef}
icon="📝"
label="Reports"
title="Report Editor"
onClick={() => setShowEditorMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showEditorMenu}
/>
{showEditorMenu && (
<>
{/* Click-away close, without a document-level listener. */}
<div
className="nav-rail-backdrop"
onClick={() => setShowEditorMenu(false)}
/>
<div className="nav-rail-flyout nav-rail-menu" role="menu">
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
)}
{currentUser?.can_manage_entities && (
<RailItem
icon="📋"
label="Lists"
title="Nomenclatures"
onClick={() => setShowNomenclatureManager(true)}
/>
)}
<RailItem
icon="🕘"
label="History"
title="History"
onClick={() => navigate('/history')}
isActive={pathname === '/history'}
/>
{currentUser?.is_admin && (
<>
<RailItem
icon="👥"
label="Users"
title="User Manager"
onClick={() => navigate('/users')}
isActive={pathname === '/users'}
/>
<RailItem
icon="📡"
label="Scale"
title="Scale Monitor"
onClick={() => navigate('/scale-monitor')}
isActive={pathname === '/scale-monitor'}
/>
{licenseWarning ? (
<LicenseWarning
warning={licenseWarning}
isAdmin
isActive={pathname === '/license'}
onOpenPage={() => navigate('/license')}
/>
) : (
<RailItem
icon="🔑"
label="Licence"
title="Licence"
onClick={() => navigate('/license')}
isActive={pathname === '/license'}
/>
)}
</>
)}
</div>
<div className="nav-rail-bottom">
{licenseWarning && !currentUser?.is_admin && (
<LicenseWarning warning={licenseWarning} isAdmin={false} />
)}
<button
className="nav-rail-theme"
onClick={toggleTheme}
title={theme === 'cockpit' ? 'Switch to the classic theme' : 'Switch to the cockpit theme'}
aria-label="Toggle colour theme"
>
{theme === 'cockpit' ? '☀' : '◐'}
</button>
<div className="nav-rail-anchor">
<button
ref={avatarButtonRef}
className="nav-rail-avatar"
onClick={() => setShowUserMenu(v => !v)}
title={currentUser?.username}
aria-label={`Account: ${currentUser?.username ?? ''}`}
aria-haspopup="menu"
aria-expanded={showUserMenu}
>
{getInitials(currentUser || {})}
</button>
{showUserMenu && (
<>
<div
className="nav-rail-backdrop"
onClick={() => setShowUserMenu(false)}
/>
<div className="nav-rail-flyout nav-rail-user-menu" role="menu">
<div className="nav-rail-user">
<strong>{currentUser?.username}</strong>
<span>{currentUser?.role}</span>
</div>
<button role="menuitem" onClick={handleChangePassword}>Change password</button>
<button role="menuitem" onClick={handleLogout}>Logout</button>
</div>
</>
)}
</div>
</div>
</nav>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
export default NavRail;
@@ -5,12 +5,12 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: var(--backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 20px; padding: var(--space-5);
animation: nmFadeIn 0.2s ease-in; animation: nmFadeIn 0.2s ease-in;
} }
@@ -21,9 +21,9 @@
/* Main content container */ /* Main content container */
.nm-content { .nm-content {
background: white; background: var(--surface);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-overlay);
width: 100%; width: 100%;
max-width: 960px; max-width: 960px;
max-height: 90vh; max-height: 90vh;
@@ -39,8 +39,8 @@
/* Header */ /* Header */
.nm-header { .nm-header {
padding: 20px 24px; padding: var(--space-5) var(--space-6);
border-bottom: 2px solid #667eea; border-bottom: 2px solid var(--accent);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -49,15 +49,15 @@
.nm-header h2 { .nm-header h2 {
margin: 0; margin: 0;
color: #667eea; color: var(--accent);
font-size: 22px; font-size: var(--fs-2xl);
} }
.nm-close-button { .nm-close-button {
background: none; background: none;
border: none; border: none;
font-size: 32px; font-size: var(--fs-display);
color: #999; color: var(--text-muted);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 32px; width: 32px;
@@ -70,8 +70,8 @@
} }
.nm-close-button:hover { .nm-close-button:hover {
background: #f5f5f5; background: var(--surface-sunken);
color: #333; color: var(--text);
} }
/* Body - two panels */ /* Body - two panels */
@@ -85,28 +85,28 @@
/* Left panel */ /* Left panel */
.nm-list-panel { .nm-list-panel {
width: 250px; width: 250px;
border-right: 1px solid #e0e0e0; border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
} }
.nm-add-btn { .nm-add-btn {
margin: 12px; margin: var(--space-3);
padding: 8px 16px; padding: var(--space-2) var(--space-4);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--accent-gradient);
color: white; color: var(--accent-contrast);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-weight: 600; font-weight: var(--fw-semibold);
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
.nm-add-btn:hover { .nm-add-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); box-shadow: 0 3px 8px var(--accent-soft);
} }
.nm-list { .nm-list {
@@ -117,19 +117,29 @@
.nm-list-item { .nm-list-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 10px 12px; padding: var(--space-2-5) var(--space-3);
cursor: pointer; cursor: pointer;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--border);
transition: background 0.15s; transition: background 0.15s;
user-select: none;
} }
.nm-list-item:hover { .nm-list-item:hover {
background: #f5f5ff; background: var(--accent-soft);
} }
.nm-list-item--active { .nm-list-item--active {
background: #eef0ff; background: var(--accent-soft);
border-left: 3px solid #667eea; border-left: 3px solid var(--accent);
}
.nm-list-item--dragging {
opacity: 0.4;
}
/* Inset shadow rather than a border, so the drop line adds no row height */
.nm-list-item--drag-over {
box-shadow: inset 0 2px 0 var(--accent);
} }
.nm-list-item-info { .nm-list-item-info {
@@ -139,9 +149,9 @@
.nm-list-item-name { .nm-list-item-name {
display: block; display: block;
font-weight: 600; font-weight: var(--fw-semibold);
font-size: 14px; font-size: var(--fs-md);
color: #333; color: var(--text);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -149,40 +159,40 @@
.nm-list-item-meta { .nm-list-item-meta {
display: block; display: block;
font-size: 11px; font-size: var(--fs-xs);
color: #999; color: var(--text-muted);
margin-top: 2px; margin-top: var(--space-0-5);
} }
.nm-edit-btn { .nm-edit-btn {
background: none; background: none;
border: none; border: none;
color: #999; color: var(--text-muted);
font-size: 16px; font-size: var(--fs-lg);
cursor: pointer; cursor: pointer;
padding: 4px; padding: var(--space-1);
border-radius: 4px; border-radius: 4px;
transition: color 0.2s, background 0.2s; transition: color 0.2s, background 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
.nm-edit-btn:hover { .nm-edit-btn:hover {
color: #667eea; color: var(--accent);
background: #f0f0ff; background: var(--accent-soft);
} }
.nm-loading { .nm-loading {
padding: 20px; padding: var(--space-5);
text-align: center; text-align: center;
color: #999; color: var(--text-muted);
font-size: 14px; font-size: var(--fs-md);
} }
.nm-empty { .nm-empty {
padding: 16px; padding: var(--space-4);
text-align: center; text-align: center;
color: #999; color: var(--text-muted);
font-size: 13px; font-size: var(--fs-base);
} }
/* Right panel */ /* Right panel */
@@ -197,51 +207,51 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
color: #999; color: var(--text-muted);
font-size: 15px; font-size: var(--fs-lg);
padding: 40px; padding: var(--space-10);
text-align: center; text-align: center;
} }
.nm-form { .nm-form {
padding: 20px 24px; padding: var(--space-5) var(--space-6);
} }
.nm-form-title { .nm-form-title {
font-size: 16px; font-size: var(--fs-lg);
font-weight: 600; font-weight: var(--fw-semibold);
color: #333; color: var(--text);
margin-bottom: 16px; margin-bottom: var(--space-4);
} }
.nm-error { .nm-error {
background-color: #fee; background-color: var(--danger-soft);
color: #c33; color: var(--danger);
padding: 10px 14px; padding: var(--space-2-5) var(--space-4);
border-radius: 6px; border-radius: 6px;
border: 1px solid #fcc; border: 1px solid var(--danger);
font-size: 13px; font-size: var(--fs-base);
margin-bottom: 14px; margin-bottom: var(--space-4);
} }
/* Form grid */ /* Form grid */
.nm-form-grid { .nm-form-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: var(--space-3);
margin-bottom: 20px; margin-bottom: var(--space-5);
} }
.nm-form-group { .nm-form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: var(--space-1);
} }
.nm-form-group label { .nm-form-group label {
font-weight: 600; font-weight: var(--fw-semibold);
color: #555; color: var(--text-muted);
font-size: 12px; font-size: var(--fs-sm);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -249,61 +259,61 @@
.nm-form-group input[type="text"], .nm-form-group input[type="text"],
.nm-form-group input[type="number"], .nm-form-group input[type="number"],
.nm-form-group select { .nm-form-group select {
padding: 8px 10px; padding: var(--space-2) var(--space-2-5);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.nm-form-group input:focus, .nm-form-group input:focus,
.nm-form-group select:focus { .nm-form-group select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.nm-form-group input:disabled, .nm-form-group input:disabled,
.nm-form-group select:disabled { .nm-form-group select:disabled {
background-color: #f5f5f5; background-color: var(--surface-sunken);
cursor: not-allowed; cursor: not-allowed;
} }
/* Sections */ /* Sections */
.nm-section { .nm-section {
margin-bottom: 20px; margin-bottom: var(--space-5);
} }
.nm-section-header { .nm-section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: var(--space-2);
padding-bottom: 6px; padding-bottom: var(--space-1-5);
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border);
} }
.nm-section-header h3 { .nm-section-header h3 {
margin: 0; margin: 0;
font-size: 14px; font-size: var(--fs-md);
color: #667eea; color: var(--accent);
font-weight: 600; font-weight: var(--fw-semibold);
} }
.nm-section-add { .nm-section-add {
background: none; background: none;
border: 1px solid #667eea; border: 1px solid var(--accent);
color: #667eea; color: var(--accent);
padding: 4px 10px; padding: var(--space-1) var(--space-2-5);
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: var(--fs-sm);
font-weight: 600; font-weight: var(--fw-semibold);
cursor: pointer; cursor: pointer;
transition: background 0.2s, color 0.2s; transition: background 0.2s, color 0.2s;
} }
.nm-section-add:hover { .nm-section-add:hover {
background: #667eea; background: var(--accent);
color: white; color: var(--accent-contrast);
} }
.nm-section-add:disabled { .nm-section-add:disabled {
@@ -313,7 +323,7 @@
/* Fields table */ /* Fields table */
.nm-fields-table { .nm-fields-table {
border: 1px solid #e0e0e0; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
@@ -321,12 +331,12 @@
.nm-fields-header { .nm-fields-header {
display: grid; display: grid;
grid-template-columns: 2fr 2fr 1.2fr 40px 32px; grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
gap: 6px; gap: var(--space-1-5);
padding: 6px 8px; padding: var(--space-1-5) var(--space-2);
background: #f8f8fc; background: var(--surface-sunken);
font-size: 11px; font-size: var(--fs-xs);
font-weight: 600; font-weight: var(--fw-semibold);
color: #666; color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -334,38 +344,38 @@
.nm-fields-row { .nm-fields-row {
display: grid; display: grid;
grid-template-columns: 2fr 2fr 1.2fr 40px 32px; grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
gap: 6px; gap: var(--space-1-5);
padding: 6px 8px; padding: var(--space-1-5) var(--space-2);
border-top: 1px solid #f0f0f0; border-top: 1px solid var(--border);
align-items: center; align-items: center;
} }
.nm-fields-row input[type="text"] { .nm-fields-row input[type="text"] {
padding: 5px 8px; padding: var(--space-1-5) var(--space-2);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: var(--fs-base);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.nm-fields-row input[type="text"]:focus { .nm-fields-row input[type="text"]:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.nm-fields-row select { .nm-fields-row select {
padding: 5px 4px; padding: var(--space-1-5) var(--space-1);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: var(--fs-base);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.nm-fields-row select:focus { .nm-fields-row select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.nm-fields-row input[type="checkbox"] { .nm-fields-row input[type="checkbox"] {
@@ -378,8 +388,8 @@
.nm-remove-btn { .nm-remove-btn {
background: none; background: none;
border: none; border: none;
color: #ccc; color: var(--border-strong);
font-size: 20px; font-size: var(--fs-2xl);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 24px; width: 24px;
@@ -392,8 +402,8 @@
} }
.nm-remove-btn:hover:not(:disabled) { .nm-remove-btn:hover:not(:disabled) {
color: #c33; color: var(--danger);
background: #fee; background: var(--danger-soft);
} }
.nm-remove-btn:disabled { .nm-remove-btn:disabled {
@@ -405,51 +415,51 @@
.nm-entries { .nm-entries {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-2);
} }
.nm-entry { .nm-entry {
border: 1px solid #e0e0e0; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: 10px 12px; padding: var(--space-2-5) var(--space-3);
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: var(--space-2-5);
} }
.nm-entry--inactive { .nm-entry--inactive {
opacity: 0.5; opacity: 0.5;
background: #fafafa; background: var(--surface-sunken);
} }
.nm-entry-fields { .nm-entry-fields {
flex: 1; flex: 1;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: var(--space-2);
} }
.nm-entry-field { .nm-entry-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: var(--space-0-5);
min-width: 100px; min-width: 100px;
flex: 1; flex: 1;
} }
.nm-entry-field label { .nm-entry-field label {
font-size: 11px; font-size: var(--fs-xs);
color: #888; color: var(--text-muted);
font-weight: 500; font-weight: var(--fw-medium);
} }
.nm-entry-field input[type="text"], .nm-entry-field input[type="text"],
.nm-entry-field input[type="number"], .nm-entry-field input[type="number"],
.nm-entry-field select { .nm-entry-field select {
padding: 5px 8px; padding: var(--space-1-5) var(--space-2);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
font-size: 13px; font-size: var(--fs-base);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -457,61 +467,61 @@
.nm-entry-field input:focus, .nm-entry-field input:focus,
.nm-entry-field select:focus { .nm-entry-field select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.nm-entry-field input[type="checkbox"] { .nm-entry-field input[type="checkbox"] {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-top: 4px; margin-top: var(--space-1);
} }
.nm-entry-actions { .nm-entry-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: var(--space-1);
flex-shrink: 0; flex-shrink: 0;
} }
.nm-entry-save { .nm-entry-save {
background: #667eea; background: var(--accent);
color: white; color: var(--accent-contrast);
border: none; border: none;
padding: 4px 10px; padding: var(--space-1) var(--space-2-5);
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: var(--fs-sm);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.nm-entry-save:hover { .nm-entry-save:hover {
background: #5568d3; background: var(--accent-hover);
} }
.nm-entry-toggle { .nm-entry-toggle {
background: #e8f5e9; background: var(--success-soft);
color: #2e7d32; color: var(--success);
border: none; border: none;
padding: 4px 10px; padding: var(--space-1) var(--space-2-5);
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: var(--fs-xs);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.nm-entry-toggle:hover { .nm-entry-toggle:hover {
background: #c8e6c9; background: color-mix(in srgb, var(--success) 25%, transparent);
} }
.nm-entry-toggle--inactive { .nm-entry-toggle--inactive {
background: #fff3e0; background: var(--warning-soft);
color: #e65100; color: var(--warning);
} }
.nm-entry-toggle--inactive:hover { .nm-entry-toggle--inactive:hover {
background: #ffe0b2; background: color-mix(in srgb, var(--warning) 25%, transparent);
} }
/* Form actions */ /* Form actions */
@@ -519,49 +529,49 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 20px; margin-top: var(--space-5);
padding-top: 16px; padding-top: var(--space-4);
border-top: 1px solid #e0e0e0; border-top: 1px solid var(--border);
} }
.nm-form-actions-right { .nm-form-actions-right {
display: flex; display: flex;
gap: 10px; gap: var(--space-2-5);
margin-left: auto; margin-left: auto;
} }
.nm-cancel-btn { .nm-cancel-btn {
padding: 8px 20px; padding: var(--space-2) var(--space-5);
background: white; background: var(--surface);
border: 1px solid #ddd; border: 1px solid var(--border);
color: #666; color: var(--text-muted);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, color 0.2s; transition: border-color 0.2s, color 0.2s;
} }
.nm-cancel-btn:hover:not(:disabled) { .nm-cancel-btn:hover:not(:disabled) {
border-color: #999; border-color: var(--border-strong);
color: #333; color: var(--text);
} }
.nm-save-btn { .nm-save-btn {
padding: 8px 24px; padding: var(--space-2) var(--space-6);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--accent-gradient);
border: none; border: none;
color: white; color: var(--accent-contrast);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-weight: 600; font-weight: var(--fw-semibold);
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
.nm-save-btn:hover:not(:disabled) { .nm-save-btn:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); box-shadow: 0 3px 8px var(--accent-soft);
} }
.nm-save-btn:disabled, .nm-save-btn:disabled,
@@ -571,19 +581,19 @@
} }
.nm-delete-btn { .nm-delete-btn {
padding: 8px 16px; padding: var(--space-2) var(--space-4);
background: white; background: var(--surface);
border: 1px solid #e57373; border: 1px solid var(--danger);
color: #c33; color: var(--danger);
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: var(--fs-base);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: background 0.2s, color 0.2s; transition: background 0.2s, color 0.2s;
} }
.nm-delete-btn:hover:not(:disabled) { .nm-delete-btn:hover:not(:disabled) {
background: #fee; background: var(--danger-soft);
} }
.nm-delete-btn:disabled { .nm-delete-btn:disabled {
@@ -607,10 +617,85 @@
width: 100%; width: 100%;
max-height: 200px; max-height: 200px;
border-right: none; border-right: none;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border);
} }
.nm-form-grid { .nm-form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* --- Selector options editor --- */
.nm-options-empty {
margin: 0;
padding: var(--space-2-5) var(--space-2);
font-size: var(--fs-base);
color: var(--text-muted);
line-height: 1.5;
}
.nm-option-row {
border-top: 1px solid var(--border);
padding: var(--space-2);
}
.nm-option-head {
display: grid;
grid-template-columns: 1fr 1fr 32px;
gap: var(--space-1-5);
align-items: center;
}
.nm-option-head input[type="text"] {
padding: var(--space-1-5) var(--space-2);
border: 1px solid var(--border);
border-radius: 4px;
font-size: var(--fs-base);
width: 100%;
box-sizing: border-box;
}
.nm-option-head input[type="text"]:focus {
outline: none;
border-color: var(--accent);
}
.nm-option-remove {
background: none;
border: none;
color: var(--danger);
font-size: var(--fs-xl);
cursor: pointer;
padding: 0;
line-height: 1;
}
.nm-option-remove:disabled {
color: var(--border-strong);
cursor: not-allowed;
}
.nm-option-reveals {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2-5);
margin-top: var(--space-1-5);
padding-left: var(--space-0-5);
}
.nm-option-reveals-label {
font-size: var(--fs-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nm-option-reveal {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--fs-base);
color: var(--text);
cursor: pointer;
}
@@ -11,6 +11,7 @@ const EMPTY_FORM = {
display_field: 'name', display_field: 'name',
sort_order: 0, sort_order: 0,
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }], fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
selector_options: [],
}; };
function NomenclatureManager({ onClose }) { function NomenclatureManager({ onClose }) {
@@ -22,6 +23,8 @@ function NomenclatureManager({ onClose }) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [dragIndex, setDragIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
// Load nomenclature list // Load nomenclature list
const loadNomenclatures = useCallback(async () => { const loadNomenclatures = useCallback(async () => {
@@ -59,6 +62,12 @@ function NomenclatureManager({ onClose }) {
required: f.required || false, required: f.required || false,
choices: f.choices || null, choices: f.choices || null,
})), })),
selector_options: (nom.selector_options || []).map(o => ({
value: o.value,
label: o.label || '',
reveals: o.reveals || [],
sort_order: o.sort_order || 0,
})),
}); });
}; };
@@ -66,7 +75,7 @@ function NomenclatureManager({ onClose }) {
const handleAdd = () => { const handleAdd = () => {
setSelectedId(null); setSelectedId(null);
setError(''); setError('');
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }] }); setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }], selector_options: [] });
}; };
// Cancel editing // Cancel editing
@@ -96,6 +105,14 @@ function NomenclatureManager({ onClose }) {
required: f.required, required: f.required,
choices: f.field_type === 'choice' ? f.choices : null, choices: f.field_type === 'choice' ? f.choices : null,
})), })),
selector_options: form.kind === 'selector'
? form.selector_options.map((o, idx) => ({
value: o.value,
label: o.label,
reveals: o.reveals,
sort_order: idx,
}))
: [],
}; };
try { try {
@@ -142,6 +159,65 @@ function NomenclatureManager({ onClose }) {
} }
}; };
// --- Selector option management ---
const addOption = () => {
setForm(prev => ({
...prev,
selector_options: [...prev.selector_options, { value: '', label: '', reveals: [], sort_order: prev.selector_options.length }],
}));
};
const updateOption = (index, key, value) => {
setForm(prev => ({
...prev,
selector_options: prev.selector_options.map((o, i) => i === index ? { ...o, [key]: value } : o),
}));
};
const removeOption = (index) => {
setForm(prev => ({
...prev,
selector_options: prev.selector_options.filter((_, i) => i !== index),
}));
};
const toggleReveal = (index, code) => {
setForm(prev => ({
...prev,
selector_options: prev.selector_options.map((o, i) => {
if (i !== index) return o;
const reveals = o.reveals.includes(code)
? o.reveals.filter(c => c !== code)
: [...o.reveals, code];
return { ...o, reveals };
}),
}));
};
// Revealable = same scope, not a selector (no nesting), and not already claimed
// by a different selector. Mirrors the server-side rule so the UI cannot build
// a configuration the API will reject.
const revealableFor = (optionIndex) => {
const claimedElsewhere = new Set();
for (const nom of nomenclatures) {
if (nom.kind !== 'selector' || nom.id === selectedId) continue;
for (const opt of nom.selector_options || []) {
for (const code of opt.reveals || []) claimedElsewhere.add(code);
}
}
const claimedHere = new Set();
(form?.selector_options || []).forEach((o, i) => {
if (i !== optionIndex) for (const code of o.reveals || []) claimedHere.add(code);
});
return nomenclatures.filter(n =>
n.kind !== 'selector' &&
n.applies_to === form?.applies_to &&
!claimedElsewhere.has(n.code) &&
!claimedHere.has(n.code)
);
};
// --- Field management --- // --- Field management ---
const addField = () => { const addField = () => {
setForm(prev => ({ setForm(prev => ({
@@ -164,6 +240,60 @@ function NomenclatureManager({ onClose }) {
})); }));
}; };
// --- Drag to reorder the nomenclature list ---
const handleDragStart = (index, e) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
// Firefox will not start a drag unless some data is set
e.dataTransfer.setData('text/plain', String(index));
};
const handleDragOver = (index, e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
const handleDrop = async (targetIndex, e) => {
e.preventDefault();
const sourceIndex = dragIndex;
setDragIndex(null);
setDragOverIndex(null);
if (sourceIndex === null || sourceIndex === targetIndex) return;
const previous = nomenclatures;
const reordered = [...previous];
const [moved] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, moved);
// Renumber and apply optimistically so the list does not snap back
const withOrder = reordered.map((nom, i) => ({ ...nom, sort_order: i }));
setNomenclatures(withOrder);
// PATCH only the rows whose position actually changed. PATCH (not PUT) is
// required: a full update recreates the nested fields collection.
const changed = withOrder.filter(
(nom, i) => previous[i]?.id !== nom.id || previous[i]?.sort_order !== i
);
try {
await Promise.all(
changed.map(nom =>
api.patch(`/api/nomenclatures/${nom.id}/`, { sort_order: nom.sort_order })
)
);
} catch {
setError('Failed to save order');
await loadNomenclatures();
}
};
const handleOverlayClick = (e) => { const handleOverlayClick = (e) => {
if (e.target.className === 'nm-overlay') { if (e.target.className === 'nm-overlay') {
onClose(); onClose();
@@ -187,16 +317,26 @@ function NomenclatureManager({ onClose }) {
{isLoading && <div className="nm-loading">Loading...</div>} {isLoading && <div className="nm-loading">Loading...</div>}
<div className="nm-list"> <div className="nm-list">
{nomenclatures.map(nom => ( {nomenclatures.map((nom, idx) => (
<div <div
key={nom.id} key={nom.id}
className={`nm-list-item ${selectedId === nom.id ? 'nm-list-item--active' : ''}`} className={[
'nm-list-item',
selectedId === nom.id ? 'nm-list-item--active' : '',
dragIndex === idx ? 'nm-list-item--dragging' : '',
dragOverIndex === idx && dragIndex !== idx ? 'nm-list-item--drag-over' : '',
].filter(Boolean).join(' ')}
onClick={() => handleSelect(nom)} onClick={() => handleSelect(nom)}
draggable={canEdit}
onDragStart={e => handleDragStart(idx, e)}
onDragOver={e => handleDragOver(idx, e)}
onDragEnd={handleDragEnd}
onDrop={e => handleDrop(idx, e)}
> >
<div className="nm-list-item-info"> <div className="nm-list-item-info">
<span className="nm-list-item-name">{nom.name}</span> <span className="nm-list-item-name">{nom.name}</span>
<span className="nm-list-item-meta"> <span className="nm-list-item-meta">
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to} {nom.kind === 'lookup' ? 'Lookup' : nom.kind === 'selector' ? 'Selector' : 'Field'} / {nom.applies_to}
</span> </span>
</div> </div>
{canEdit && ( {canEdit && (
@@ -276,6 +416,7 @@ function NomenclatureManager({ onClose }) {
> >
<option value="lookup">Lookup Table</option> <option value="lookup">Lookup Table</option>
<option value="field">Custom Field</option> <option value="field">Custom Field</option>
<option value="selector">Selector</option>
</select> </select>
</div> </div>
@@ -295,7 +436,74 @@ function NomenclatureManager({ onClose }) {
)} )}
</div> </div>
{/* Options section - a selector's choices replace the field editor */}
{form.kind === 'selector' && (
<div className="nm-section">
<div className="nm-section-header">
<h3>Options</h3>
{canEdit && (
<button className="nm-section-add" onClick={addOption} disabled={isSaving}>
+ Add Option
</button>
)}
</div>
{form.selector_options.length === 0 && (
<p className="nm-options-empty">
No options yet. Each option is a choice the operator can pick,
and the nomenclatures it reveals appear beneath it.
</p>
)}
{form.selector_options.map((opt, idx) => (
<div key={idx} className="nm-option-row">
<div className="nm-option-head">
<input
type="text"
placeholder="Label (e.g. Ship)"
value={opt.label}
onChange={e => updateOption(idx, 'label', e.target.value)}
disabled={isSaving || !canEdit}
/>
<input
type="text"
placeholder="Value (e.g. ship)"
value={opt.value}
onChange={e => updateOption(idx, 'value', e.target.value)}
disabled={isSaving || !canEdit}
/>
{canEdit && (
<button
className="nm-option-remove"
onClick={() => removeOption(idx)}
disabled={isSaving}
title="Remove option"
>
&times;
</button>
)}
</div>
<div className="nm-option-reveals">
<span className="nm-option-reveals-label">Reveals:</span>
{revealableFor(idx).map(n => (
<label key={n.code} className="nm-option-reveal">
<input
type="checkbox"
checked={opt.reveals.includes(n.code)}
onChange={() => toggleReveal(idx, n.code)}
disabled={isSaving || !canEdit}
/>
{n.name}
</label>
))}
</div>
</div>
))}
</div>
)}
{/* Fields section */} {/* Fields section */}
{form.kind !== 'selector' && (
<div className="nm-section"> <div className="nm-section">
<div className="nm-section-header"> <div className="nm-section-header">
<h3>Fields</h3> <h3>Fields</h3>
@@ -358,6 +566,7 @@ function NomenclatureManager({ onClose }) {
))} ))}
</div> </div>
</div> </div>
)}
{/* Actions */} {/* Actions */}
<div className="nm-form-actions"> <div className="nm-form-actions">
@@ -5,7 +5,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 13000; z-index: 13000;
background-color: rgba(0, 0, 0, 0.7); background-color: var(--backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -21,9 +21,9 @@
width: 90%; width: 90%;
max-width: 600px; max-width: 600px;
max-height: 80vh; max-height: 80vh;
background: white; background: var(--surface);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-overlay);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
animation: nuiFormSlideUp 0.15s ease-out; animation: nuiFormSlideUp 0.15s ease-out;
@@ -38,18 +38,18 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.5rem; padding: var(--space-4) var(--space-6);
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--border);
background-color: #f8f9fa; background-color: var(--surface-sunken);
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
flex-shrink: 0; flex-shrink: 0;
} }
.nui-form-modal-header h3 { .nui-form-modal-header h3 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: var(--fs-xl);
font-weight: 600; font-weight: var(--fw-semibold);
color: #333; color: var(--text);
} }
.nui-form-modal-close { .nui-form-modal-close {
@@ -60,8 +60,8 @@
justify-content: center; justify-content: center;
background: none; background: none;
border: none; border: none;
font-size: 1.4rem; font-size: var(--fs-2xl);
color: #666; color: var(--text-muted);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s, color 0.2s; transition: background-color 0.2s, color 0.2s;
@@ -69,12 +69,12 @@
} }
.nui-form-modal-close:hover { .nui-form-modal-close:hover {
background-color: #e9ecef; background-color: var(--surface-hover);
color: #333; color: var(--text);
} }
.nui-form-modal-content { .nui-form-modal-content {
padding: 1.5rem; padding: var(--space-6);
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
@@ -1,13 +1,13 @@
.nui-dropdown { .nui-dropdown {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: var(--space-1);
} }
.nui-dropdown-label { .nui-dropdown-label {
font-size: 12px; font-size: var(--fs-sm);
font-weight: 600; font-weight: var(--fw-semibold);
color: #555; color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -15,58 +15,58 @@
.nui-dropdown-label--checkbox { .nui-dropdown-label--checkbox {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-2);
cursor: pointer; cursor: pointer;
text-transform: uppercase; text-transform: uppercase;
} }
.nui-dropdown-row { .nui-dropdown-row {
display: flex; display: flex;
gap: 4px; gap: var(--space-1);
} }
.nui-dropdown-select { .nui-dropdown-select {
flex: 1; flex: 1;
padding: 7px 10px; padding: var(--space-2) var(--space-2-5);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
background-color: white; background-color: var(--surface);
color: #333; color: var(--text);
transition: border-color 0.2s; transition: border-color 0.2s;
min-width: 0; min-width: 0;
} }
.nui-dropdown-select:focus { .nui-dropdown-select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.nui-dropdown-select:disabled { .nui-dropdown-select:disabled {
background-color: #f0f0f0; background-color: var(--surface-hover);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
.nui-dropdown-input { .nui-dropdown-input {
width: 100%; width: 100%;
padding: 7px 10px; padding: var(--space-2) var(--space-2-5);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
background-color: white; background-color: var(--surface);
color: #333; color: var(--text);
transition: border-color 0.2s; transition: border-color 0.2s;
box-sizing: border-box; box-sizing: border-box;
} }
.nui-dropdown-input:focus { .nui-dropdown-input:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.nui-dropdown-input:disabled { .nui-dropdown-input:disabled {
background-color: #f0f0f0; background-color: var(--surface-hover);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
@@ -94,11 +94,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: white; background: var(--surface);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 18px; font-size: var(--fs-xl);
color: #666; color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0; flex-shrink: 0;
@@ -107,9 +107,9 @@
} }
.nui-dropdown-manage-btn:hover:not(:disabled) { .nui-dropdown-manage-btn:hover:not(:disabled) {
border-color: #667eea; border-color: var(--accent);
color: #667eea; color: var(--accent);
background-color: #f0f0ff; background-color: var(--accent-soft);
} }
.nui-dropdown-manage-btn:disabled { .nui-dropdown-manage-btn:disabled {
@@ -35,6 +35,33 @@ export default function NomenclatureDropdown({
const displayLabel = label || nomenclature.name; const displayLabel = label || nomenclature.name;
// SELECTOR type: choices come from selector_options, value is the option string.
// No manage button - options are configured in the admin screen, not while weighing.
if (nomenclature.kind === 'selector') {
const options = nomenclature.selector_options || [];
const isUnknown = value && !options.some(o => o.value === value);
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label">{displayLabel}</label>
<select
className="nui-dropdown-select"
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled}
>
<option value="">-- Select --</option>
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
{isUnknown && (
<option value={value}>{value} (unknown)</option>
)}
</select>
</div>
);
}
// LOOKUP type: select dropdown + manage button // LOOKUP type: select dropdown + manage button
if (nomenclature.kind === 'lookup') { if (nomenclature.kind === 'lookup') {
const handleSelectChange = (e) => { const handleSelectChange = (e) => {
@@ -1,31 +1,31 @@
.nui-entry-form { .nui-entry-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: var(--space-4);
} }
.nui-entry-form-field { .nui-entry-form-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: var(--space-2);
} }
.nui-entry-form-field > label { .nui-entry-form-field > label {
font-weight: 500; font-weight: var(--fw-medium);
color: #333; color: var(--text);
font-size: 0.9rem; font-size: var(--fs-md);
} }
.nui-entry-form-field input[type="text"], .nui-entry-form-field input[type="text"],
.nui-entry-form-field input[type="number"], .nui-entry-form-field input[type="number"],
.nui-entry-form-field select { .nui-entry-form-field select {
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: var(--space-2) var(--space-3);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 4px; border-radius: 4px;
font-size: 0.95rem; font-size: var(--fs-lg);
background-color: white; background-color: var(--surface);
color: #333; color: var(--text);
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -34,14 +34,14 @@
.nui-entry-form-field input[type="number"]:focus, .nui-entry-form-field input[type="number"]:focus,
.nui-entry-form-field select:focus { .nui-entry-form-field select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 3px var(--accent-soft);
} }
.nui-entry-form-field input[type="text"]:disabled, .nui-entry-form-field input[type="text"]:disabled,
.nui-entry-form-field input[type="number"]:disabled, .nui-entry-form-field input[type="number"]:disabled,
.nui-entry-form-field select:disabled { .nui-entry-form-field select:disabled {
background-color: #f0f0f0; background-color: var(--surface-hover);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
@@ -58,23 +58,23 @@
/* Error state */ /* Error state */
.nui-entry-form-input-error { .nui-entry-form-input-error {
border-color: #dc3545 !important; border-color: var(--danger) !important;
} }
.nui-entry-form-error { .nui-entry-form-error {
font-size: 0.8rem; font-size: var(--fs-base);
color: #dc3545; color: var(--danger);
} }
/* Checkbox field */ /* Checkbox field */
.nui-entry-form-checkbox-label { .nui-entry-form-checkbox-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: var(--space-2);
cursor: pointer; cursor: pointer;
font-weight: normal !important; font-weight: var(--fw-regular) !important;
font-size: 0.95rem; font-size: var(--fs-lg);
color: #333; color: var(--text);
} }
.nui-entry-form-checkbox { .nui-entry-form-checkbox {
@@ -86,27 +86,27 @@
/* Actions */ /* Actions */
.nui-entry-form-actions { .nui-entry-form-actions {
display: flex; display: flex;
gap: 0.75rem; gap: var(--space-3);
justify-content: flex-end; justify-content: flex-end;
margin-top: 1rem; margin-top: var(--space-4);
padding-top: 1rem; padding-top: var(--space-4);
border-top: 1px solid #eee; border-top: 1px solid var(--border);
} }
.nui-entry-form-cancel { .nui-entry-form-cancel {
background-color: #6c757d; background-color: var(--text-muted);
color: white; color: var(--text-inverse);
padding: 0.5rem 1.5rem; padding: var(--space-2) var(--space-6);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 0.95rem; font-size: var(--fs-lg);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s, transform 0.1s; transition: background-color 0.2s, transform 0.1s;
} }
.nui-entry-form-cancel:hover { .nui-entry-form-cancel:hover {
background-color: #5a6268; background-color: var(--text);
} }
.nui-entry-form-cancel:active { .nui-entry-form-cancel:active {
@@ -114,19 +114,19 @@
} }
.nui-entry-form-save { .nui-entry-form-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--accent-gradient);
color: white; color: var(--accent-contrast);
padding: 0.5rem 1.5rem; padding: var(--space-2) var(--space-6);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 0.95rem; font-size: var(--fs-lg);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: box-shadow 0.2s, transform 0.1s; transition: box-shadow 0.2s, transform 0.1s;
} }
.nui-entry-form-save:hover:not(:disabled) { .nui-entry-form-save:hover:not(:disabled) {
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); box-shadow: 0 3px 8px var(--accent-soft);
} }
.nui-entry-form-save:active:not(:disabled) { .nui-entry-form-save:active:not(:disabled) {
@@ -5,7 +5,7 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 12000; z-index: 12000;
background-color: rgba(0, 0, 0, 0.5); background-color: var(--backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -21,9 +21,9 @@
width: 90%; width: 90%;
max-width: 1200px; max-width: 1200px;
max-height: 90vh; max-height: 90vh;
background: white; background: var(--surface);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-overlay);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
animation: nuiMgmtSlideUp 0.15s ease-out; animation: nuiMgmtSlideUp 0.15s ease-out;
@@ -39,18 +39,18 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.5rem; padding: var(--space-4) var(--space-6);
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid var(--border);
background-color: #f8f9fa; background-color: var(--surface-sunken);
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
flex-shrink: 0; flex-shrink: 0;
} }
.nui-mgmt-header h2 { .nui-mgmt-header h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: var(--fs-xl);
font-weight: 600; font-weight: var(--fw-semibold);
color: #333; color: var(--text);
} }
.nui-mgmt-close { .nui-mgmt-close {
@@ -61,8 +61,8 @@
justify-content: center; justify-content: center;
background: none; background: none;
border: none; border: none;
font-size: 1.5rem; font-size: var(--fs-2xl);
color: #666; color: var(--text-muted);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s, color 0.2s; transition: background-color 0.2s, color 0.2s;
@@ -70,60 +70,60 @@
} }
.nui-mgmt-close:hover { .nui-mgmt-close:hover {
background-color: #e9ecef; background-color: var(--surface-hover);
color: #333; color: var(--text);
} }
/* Toolbar */ /* Toolbar */
.nui-mgmt-toolbar { .nui-mgmt-toolbar {
display: flex; display: flex;
gap: 1rem; gap: var(--space-4);
padding: 1rem 1.5rem; padding: var(--space-4) var(--space-6);
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
.nui-mgmt-search { .nui-mgmt-search {
flex: 1; flex: 1;
padding: 0.5rem 1rem; padding: var(--space-2) var(--space-4);
border: 1px solid #ced4da; border: 1px solid var(--border-strong);
border-radius: 4px; border-radius: 4px;
font-size: 0.95rem; font-size: var(--fs-lg);
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.nui-mgmt-search:focus { .nui-mgmt-search:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25); box-shadow: 0 0 0 0.2rem var(--accent-soft);
} }
.nui-mgmt-add-btn { .nui-mgmt-add-btn {
background-color: #28a745; background-color: var(--success);
color: white; color: var(--text-inverse);
padding: 0.5rem 1rem; padding: var(--space-2) var(--space-4);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 0.95rem; font-size: var(--fs-lg);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.nui-mgmt-add-btn:hover { .nui-mgmt-add-btn:hover {
background-color: #218838; background-color: color-mix(in srgb, var(--success) 85%, var(--text));
} }
/* Error */ /* Error */
.nui-mgmt-error { .nui-mgmt-error {
margin: 0.5rem 1.5rem; margin: var(--space-2) var(--space-6);
padding: 0.5rem 1rem; padding: var(--space-2) var(--space-4);
background-color: #fee; background-color: var(--danger-soft);
color: #c33; color: var(--danger);
border: 1px solid #fcc; border: 1px solid var(--danger);
border-radius: 4px; border-radius: 4px;
font-size: 0.85rem; font-size: var(--fs-md);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -141,17 +141,17 @@
} }
.nui-mgmt-table-container::-webkit-scrollbar-track { .nui-mgmt-table-container::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--surface-sunken);
border-radius: 4px; border-radius: 4px;
} }
.nui-mgmt-table-container::-webkit-scrollbar-thumb { .nui-mgmt-table-container::-webkit-scrollbar-thumb {
background: #888; background: var(--text-muted);
border-radius: 4px; border-radius: 4px;
} }
.nui-mgmt-table-container::-webkit-scrollbar-thumb:hover { .nui-mgmt-table-container::-webkit-scrollbar-thumb:hover {
background: #555; background: var(--text);
} }
/* Table */ /* Table */
@@ -161,19 +161,19 @@
} }
.nui-mgmt-table thead { .nui-mgmt-table thead {
background-color: #f8f9fa; background-color: var(--surface-sunken);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
} }
.nui-mgmt-table th { .nui-mgmt-table th {
padding: 0.6rem 1rem; padding: var(--space-2-5) var(--space-4);
text-align: left; text-align: left;
font-weight: 600; font-weight: var(--fw-semibold);
color: #495057; color: var(--text);
font-size: 0.85rem; font-size: var(--fs-md);
border-bottom: 2px solid #dee2e6; border-bottom: 2px solid var(--border);
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.3px;
@@ -195,16 +195,16 @@
} }
.nui-mgmt-table td { .nui-mgmt-table td {
padding: 0.5rem 1rem; padding: var(--space-2) var(--space-4);
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid var(--border);
font-size: 0.95rem; font-size: var(--fs-lg);
color: #333; color: var(--text);
} }
.nui-mgmt-td-id { .nui-mgmt-td-id {
text-align: center; text-align: center;
color: #888; color: var(--text-muted);
font-size: 0.85rem; font-size: var(--fs-md);
} }
.nui-mgmt-td-status { .nui-mgmt-td-status {
@@ -222,15 +222,15 @@
} }
.nui-mgmt-row:hover { .nui-mgmt-row:hover {
background-color: #f0f0ff; background-color: var(--surface-hover);
} }
.nui-mgmt-row--selected { .nui-mgmt-row--selected {
background-color: #e8eaff; background-color: var(--accent-soft);
} }
.nui-mgmt-row--selected:hover { .nui-mgmt-row--selected:hover {
background-color: #dcdeff; background-color: color-mix(in srgb, var(--accent) 20%, transparent);
} }
.nui-mgmt-row--inactive { .nui-mgmt-row--inactive {
@@ -239,7 +239,7 @@
.nui-mgmt-row--inactive td { .nui-mgmt-row--inactive td {
text-decoration: line-through; text-decoration: line-through;
color: #999; color: var(--text-muted);
} }
.nui-mgmt-row--inactive .nui-mgmt-td-id, .nui-mgmt-row--inactive .nui-mgmt-td-id,
@@ -251,56 +251,56 @@
/* Active badge */ /* Active badge */
.nui-mgmt-active-badge { .nui-mgmt-active-badge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: var(--space-0-5) var(--space-2);
border-radius: 10px; border-radius: 10px;
font-size: 0.75rem; font-size: var(--fs-sm);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
user-select: none; user-select: none;
} }
.nui-mgmt-active-badge--active { .nui-mgmt-active-badge--active {
background-color: #d4edda; background-color: var(--success-soft);
color: #155724; color: var(--success);
} }
.nui-mgmt-active-badge--active:hover { .nui-mgmt-active-badge--active:hover {
background-color: #c3e6cb; background-color: color-mix(in srgb, var(--success) 25%, transparent);
} }
.nui-mgmt-active-badge--inactive { .nui-mgmt-active-badge--inactive {
background-color: #f8d7da; background-color: var(--danger-soft);
color: #721c24; color: var(--danger);
} }
.nui-mgmt-active-badge--inactive:hover { .nui-mgmt-active-badge--inactive:hover {
background-color: #f5c6cb; background-color: color-mix(in srgb, var(--danger) 25%, transparent);
} }
/* Edit button in row */ /* Edit button in row */
.nui-mgmt-edit-btn { .nui-mgmt-edit-btn {
background: none; background: none;
border: 1px solid #dee2e6; border: 1px solid var(--border);
padding: 0.2rem 0.5rem; padding: var(--space-1) var(--space-2);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.85rem; font-size: var(--fs-md);
color: #666; color: var(--text-muted);
} }
.nui-mgmt-edit-btn:hover { .nui-mgmt-edit-btn:hover {
background-color: #e9ecef; background-color: var(--surface-hover);
border-color: #adb5bd; border-color: var(--border-strong);
color: #333; color: var(--text);
} }
/* Empty state */ /* Empty state */
.nui-mgmt-empty { .nui-mgmt-empty {
text-align: center; text-align: center;
padding: 2rem !important; padding: var(--space-8) !important;
color: #999; color: var(--text-muted);
font-style: italic; font-style: italic;
} }
@@ -309,75 +309,75 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem 1.5rem; padding: var(--space-4) var(--space-6);
border-top: 1px solid #dee2e6; border-top: 1px solid var(--border);
background-color: #f8f9fa; background-color: var(--surface-sunken);
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.nui-mgmt-footer-info { .nui-mgmt-footer-info {
font-size: 0.85rem; font-size: var(--fs-md);
color: #666; color: var(--text-muted);
} }
.nui-mgmt-footer-actions { .nui-mgmt-footer-actions {
display: flex; display: flex;
gap: 0.5rem; gap: var(--space-2);
} }
.nui-mgmt-footer-edit-btn { .nui-mgmt-footer-edit-btn {
background: none; background: none;
border: 1px solid #667eea; border: 1px solid var(--accent);
color: #667eea; color: var(--accent);
padding: 0.4rem 1rem; padding: var(--space-1-5) var(--space-4);
border-radius: 4px; border-radius: 4px;
font-size: 0.9rem; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.nui-mgmt-footer-edit-btn:hover { .nui-mgmt-footer-edit-btn:hover {
background-color: #667eea; background-color: var(--accent);
color: white; color: var(--accent-contrast);
} }
.nui-mgmt-delete-btn { .nui-mgmt-delete-btn {
background: none; background: none;
border: 1px solid #dc3545; border: 1px solid var(--danger);
color: #dc3545; color: var(--danger);
padding: 0.4rem 1rem; padding: var(--space-1-5) var(--space-4);
border-radius: 4px; border-radius: 4px;
font-size: 0.9rem; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.nui-mgmt-delete-btn:hover { .nui-mgmt-delete-btn:hover {
background-color: #dc3545; background-color: var(--danger);
color: white; color: var(--text-inverse);
} }
.nui-mgmt-select-btn { .nui-mgmt-select-btn {
background-color: #667eea; background-color: var(--accent);
color: white; color: var(--accent-contrast);
padding: 0.4rem 1.5rem; padding: var(--space-1-5) var(--space-6);
border: none; border: none;
border-radius: 4px; border-radius: 4px;
font-size: 0.9rem; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.nui-mgmt-select-btn:hover:not(:disabled) { .nui-mgmt-select-btn:hover:not(:disabled) {
background-color: #5568d3; background-color: var(--accent-hover);
} }
.nui-mgmt-select-btn:disabled { .nui-mgmt-select-btn:disabled {
background-color: #6c757d; background-color: var(--text-muted);
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import NavRail from './NavRail';
// Shell for every screen that carries the navigation rail. Mounted once as a
// layout route, so the rail does not remount (and lose an open menu) when the
// user moves between the pages inside it.
function RailLayout() {
return (
<div className="app-shell">
<main className="app-shell-content">
<Outlet />
</main>
<NavRail />
</div>
);
}
export default RailLayout;
@@ -4,9 +4,9 @@
top: 0; top: 0;
width: 300px; width: 300px;
height: 100vh; height: 100vh;
background: white; background: var(--surface);
border-left: 1px solid #ccc; border-left: 1px solid var(--border-strong);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-raised);
overflow-y: auto; overflow-y: auto;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
@@ -17,9 +17,9 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 15px; padding: var(--space-4);
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border);
background: #f9f9f9; background: var(--surface-sunken);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
@@ -27,41 +27,41 @@
.palette-header h3 { .palette-header h3 {
margin: 0; margin: 0;
font-size: 16px; font-size: var(--fs-lg);
color: #333; color: var(--text);
} }
.close-button { .close-button {
background: transparent; background: transparent;
border: none; border: none;
font-size: 20px; font-size: var(--fs-2xl);
cursor: pointer; cursor: pointer;
color: #666; color: var(--text-muted);
padding: 5px 10px; padding: var(--space-1-5) var(--space-2-5);
line-height: 1; line-height: 1;
} }
.close-button:hover { .close-button:hover {
color: #333; color: var(--text);
background: rgba(0, 0, 0, 0.05); background: var(--surface-hover);
border-radius: 3px; border-radius: 3px;
} }
.palette-content { .palette-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 10px; padding: var(--space-2-5);
} }
.palette-category { .palette-category {
margin-bottom: 10px; margin-bottom: var(--space-2-5);
} }
.category-header { .category-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 10px; padding: var(--space-2-5);
background: #f5f5f5; background: var(--surface-sunken);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
user-select: none; user-select: none;
@@ -69,32 +69,32 @@
} }
.category-header:hover { .category-header:hover {
background: #e8e8e8; background: var(--surface-hover);
} }
.category-header.expanded { .category-header.expanded {
background: #667eea; background: var(--accent);
color: white; color: var(--accent-contrast);
} }
.category-icon { .category-icon {
margin-right: 8px; margin-right: var(--space-2);
font-size: 12px; font-size: var(--fs-sm);
width: 12px; width: 12px;
display: inline-block; display: inline-block;
} }
.category-name { .category-name {
font-weight: 500; font-weight: var(--fw-medium);
font-size: 14px; font-size: var(--fs-md);
} }
.character-grid { .character-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 5px; gap: var(--space-1-5);
padding: 10px; padding: var(--space-2-5);
background: #fafafa; background: var(--surface-sunken);
border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px;
} }
@@ -104,27 +104,27 @@
justify-content: center; justify-content: center;
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 1px solid #ddd; border: 1px solid var(--border);
background: white; background: var(--surface);
cursor: pointer; cursor: pointer;
font-size: 24px; font-size: var(--fs-2xl);
font-family: 'Courier New', 'Consolas', monospace; font-family: var(--font-mono);
transition: all 0.2s; transition: all 0.2s;
border-radius: 3px; border-radius: 3px;
user-select: none; user-select: none;
} }
.char-button:hover { .char-button:hover {
border-color: #667eea; border-color: var(--accent);
background: #f0f0ff; background: var(--accent-soft);
transform: scale(1.1); transform: scale(1.1);
} }
.char-button.active { .char-button.active {
background: #667eea; background: var(--accent);
color: white; color: var(--accent-contrast);
border-color: #667eea; border-color: var(--accent);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3); box-shadow: 0 2px 4px var(--accent-soft);
} }
/* Scrollbar styling */ /* Scrollbar styling */
@@ -135,16 +135,16 @@
.character-palette::-webkit-scrollbar-track, .character-palette::-webkit-scrollbar-track,
.palette-content::-webkit-scrollbar-track { .palette-content::-webkit-scrollbar-track {
background: #f1f1f1; background: var(--surface-sunken);
} }
.character-palette::-webkit-scrollbar-thumb, .character-palette::-webkit-scrollbar-thumb,
.palette-content::-webkit-scrollbar-thumb { .palette-content::-webkit-scrollbar-thumb {
background: #c0c0c0; background: var(--border-strong);
border-radius: 4px; border-radius: 4px;
} }
.character-palette::-webkit-scrollbar-thumb:hover, .character-palette::-webkit-scrollbar-thumb:hover,
.palette-content::-webkit-scrollbar-thumb:hover { .palette-content::-webkit-scrollbar-thumb:hover {
background: #a0a0a0; background: var(--text-muted);
} }
@@ -4,7 +4,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: var(--backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -12,38 +12,38 @@
} }
.config-panel { .config-panel {
background: white; background: var(--surface);
border-radius: 8px; border-radius: 8px;
width: 90%; width: 90%;
max-width: 600px; max-width: 600px;
max-height: 80vh; max-height: 80vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); box-shadow: var(--shadow-overlay);
} }
.config-panel-header { .config-panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px; padding: var(--space-5);
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--header-bg);
color: white; color: var(--header-text);
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
} }
.config-panel-header h2 { .config-panel-header h2 {
margin: 0; margin: 0;
font-size: 20px; font-size: var(--fs-2xl);
font-weight: 600; font-weight: var(--fw-semibold);
} }
.close-button { .close-button {
background: none; background: none;
border: none; border: none;
color: white; color: var(--header-text);
font-size: 32px; font-size: var(--fs-display);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 32px; width: 32px;
@@ -56,70 +56,70 @@
} }
.close-button:hover { .close-button:hover {
background: rgba(255, 255, 255, 0.2); background: var(--header-chrome);
} }
.config-panel-content { .config-panel-content {
padding: 20px; padding: var(--space-5);
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
} }
.config-section { .config-section {
margin-bottom: 24px; margin-bottom: var(--space-6);
} }
.config-section h3 { .config-section h3 {
margin: 0 0 12px 0; margin: 0 0 var(--space-3) 0;
font-size: 16px; font-size: var(--fs-lg);
font-weight: 600; font-weight: var(--fw-semibold);
color: #333; color: var(--text);
} }
.section-header { .section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: var(--space-3);
} }
.input-group { .input-group {
display: flex; display: flex;
gap: 8px; gap: var(--space-2);
} }
.config-input { .config-input {
flex: 1; flex: 1;
padding: 10px 12px; padding: var(--space-2-5) var(--space-3);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-family: 'Courier New', monospace; font-family: var(--font-mono);
} }
.config-input:focus { .config-input:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.config-input:disabled { .config-input:disabled {
background: #f5f5f5; background: var(--surface-sunken);
cursor: not-allowed; cursor: not-allowed;
} }
.config-button { .config-button {
padding: 10px 16px; padding: var(--space-2-5) var(--space-4);
background: #f0f0f0; background: var(--surface-sunken);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.config-button:hover:not(:disabled) { .config-button:hover:not(:disabled) {
background: #e0e0e0; background: var(--surface-hover);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -129,8 +129,8 @@
} }
.config-button.primary { .config-button.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--accent-gradient);
color: white; color: var(--accent-contrast);
border-color: transparent; border-color: transparent;
} }
@@ -139,65 +139,65 @@
} }
.config-button.small { .config-button.small {
padding: 6px 12px; padding: var(--space-1-5) var(--space-3);
font-size: 12px; font-size: var(--fs-sm);
} }
.config-error { .config-error {
margin-top: 8px; margin-top: var(--space-2);
padding: 12px; padding: var(--space-3);
background: #fee; background: var(--danger-soft);
border: 1px solid #fcc; border: 1px solid var(--danger);
border-radius: 6px; border-radius: 6px;
color: #c33; color: var(--danger);
font-size: 14px; font-size: var(--fs-md);
} }
.config-info { .config-info {
padding: 16px; padding: var(--space-4);
background: #f0f8ff; background: var(--accent-soft);
border: 1px solid #d0e8ff; border: 1px solid var(--accent);
border-radius: 6px; border-radius: 6px;
color: #0066cc; color: var(--accent);
font-size: 14px; font-size: var(--fs-md);
text-align: center; text-align: center;
} }
.manual-input-section { .manual-input-section {
margin-top: 12px; margin-top: var(--space-3);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-2);
} }
.manual-data-input { .manual-data-input {
padding: 10px 12px; padding: var(--space-2-5) var(--space-3);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-family: 'Courier New', monospace; font-family: var(--font-mono);
resize: vertical; resize: vertical;
min-height: 120px; min-height: 120px;
} }
.manual-data-input:focus { .manual-data-input:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.data-preview { .data-preview {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: var(--space-3);
background: #f5f5f5; background: var(--surface-sunken);
border: 1px solid #ddd; border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
} }
.data-preview pre { .data-preview pre {
margin: 0; margin: 0;
font-size: 12px; font-size: var(--fs-sm);
font-family: 'Courier New', monospace; font-family: var(--font-mono);
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
@@ -3,8 +3,8 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: 20px; padding: var(--space-5);
background: #f5f5f5; background: var(--surface-sunken);
overflow: auto; overflow: auto;
} }
@@ -13,6 +13,7 @@
width: 1440px; width: 1440px;
height: 2112px; height: 2112px;
background: white; background: white;
color: #000;
border: 1px solid #ccc; border: 1px solid #ccc;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
font-family: 'Courier New', 'Consolas', monospace; font-family: 'Courier New', 'Consolas', monospace;
@@ -58,8 +59,8 @@
.frame-preview { .frame-preview {
position: absolute; position: absolute;
border: 2px dashed #667eea; border: 2px dashed var(--accent);
background: rgba(102, 126, 234, 0.1); background: var(--accent-soft);
pointer-events: none; pointer-events: none;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -74,8 +75,8 @@
.selection-rectangle { .selection-rectangle {
position: absolute; position: absolute;
border: 2px dashed #667eea; border: 2px dashed var(--accent);
background: rgba(102, 126, 234, 0.1); background: var(--accent-soft);
pointer-events: none; pointer-events: none;
box-sizing: border-box; box-sizing: border-box;
z-index: 100; z-index: 100;
@@ -350,6 +350,12 @@ function EditorCanvas({
// Only update if position actually changed // Only update if position actually changed
if (col !== element.x || row !== element.y) { if (col !== element.x || row !== element.y) {
// A band always spans the full page width from x=0, so it moves only up
// and down; dragging one sideways would leave a gap no child can print in.
const move = (el, x, y) => onElementUpdate(el.id, el.type === 'band'
? { y }
: { x, y });
// If this element is selected with others, move all selected elements // If this element is selected with others, move all selected elements
if (selectedElementIds.includes(dragData.elementId) && selectedElementIds.length > 1) { if (selectedElementIds.includes(dragData.elementId) && selectedElementIds.length > 1) {
const deltaCol = col - element.x; const deltaCol = col - element.x;
@@ -357,11 +363,11 @@ function EditorCanvas({
selectedElementIds.forEach(id => { selectedElementIds.forEach(id => {
const el = elements.find(e => e.id === id); const el = elements.find(e => e.id === id);
if (el) { if (el) {
onElementUpdate(id, { x: el.x + deltaCol, y: el.y + deltaRow }); move(el, el.x + deltaCol, el.y + deltaRow);
} }
}); });
} else { } else {
onElementUpdate(dragData.elementId, { x: col, y: row }); move(element, col, row);
} }
} }
}; };
@@ -4,93 +4,93 @@
top: 0; top: 0;
width: 250px; width: 250px;
height: 100vh; height: 100vh;
background: #f5f5f5; background: var(--surface-sunken);
border-right: 1px solid #ddd; border-right: 1px solid var(--border);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-raised);
overflow-y: auto; overflow-y: auto;
font-family: Arial, sans-serif; font-family: var(--font-sans);
font-size: 13px; font-size: var(--fs-base);
z-index: 1000; z-index: 1000;
} }
.inspector-header { .inspector-header {
background: #e0e0e0; background: var(--surface-hover);
padding: 10px; padding: var(--space-2-5);
font-weight: bold; font-weight: var(--fw-bold);
border-bottom: 1px solid #ccc; border-bottom: 1px solid var(--border-strong);
} }
.inspector-empty { .inspector-empty {
padding: 20px; padding: var(--space-5);
color: #999; color: var(--text-muted);
text-align: center; text-align: center;
} }
.inspector-section { .inspector-section {
border-bottom: 1px solid #ddd; border-bottom: 1px solid var(--border);
padding: 10px; padding: var(--space-2-5);
} }
.section-title { .section-title {
font-weight: bold; font-weight: var(--fw-bold);
margin-bottom: 10px; margin-bottom: var(--space-2-5);
color: #555; color: var(--text-muted);
} }
.property-row { .property-row {
display: flex; display: flex;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: var(--space-2);
} }
.property-row label { .property-row label {
flex: 0 0 70px; flex: 0 0 70px;
color: #666; color: var(--text-muted);
font-size: 12px; font-size: var(--fs-sm);
} }
.property-row input, .property-row input,
.property-row select { .property-row select {
flex: 1; flex: 1;
padding: 4px 6px; padding: var(--space-1) var(--space-1-5);
border: 1px solid #ccc; border: 1px solid var(--border-strong);
border-radius: 3px; border-radius: 3px;
font-size: 12px; font-size: var(--fs-sm);
} }
.property-row input:disabled { .property-row input:disabled {
background: #e0e0e0; background: var(--surface-hover);
color: #999; color: var(--text-muted);
} }
.property-row select:disabled { .property-row select:disabled {
background: #e0e0e0; background: var(--surface-hover);
color: #999; color: var(--text-muted);
} }
.property-row input:focus, .property-row input:focus,
.property-row select:focus { .property-row select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: var(--accent);
} }
.expr-btn-row { .expr-btn-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 3px; gap: var(--space-1);
margin-bottom: 6px; margin-bottom: var(--space-1-5);
} }
.expr-btn-row button { .expr-btn-row button {
padding: 2px 6px; padding: var(--space-0-5) var(--space-1-5);
font-size: 11px; font-size: var(--fs-xs);
border: 1px solid #ccc; border: 1px solid var(--border-strong);
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
background: #fff; background: var(--surface);
} }
.expr-btn-row button:hover { .expr-btn-row button:hover {
background: #e8eeff; background: var(--accent-soft);
border-color: #667eea; border-color: var(--accent);
} }
@@ -176,11 +176,11 @@ function ObjectInspector({ element, onUpdate, allElements = [] }) {
{parentBand ? ( {parentBand ? (
<> <>
<div className="property-info" style={{ <div className="property-info" style={{
fontSize: '11px', fontSize: 'var(--fs-xs)',
color: '#667eea', color: 'var(--accent)',
marginBottom: '8px', marginBottom: 'var(--space-2)',
padding: '6px', padding: 'var(--space-1-5)',
background: 'rgba(102, 126, 234, 0.1)', background: 'var(--accent-soft)',
borderRadius: '3px' borderRadius: '3px'
}}> }}>
{parentBand.parentBandId ? ( {parentBand.parentBandId ? (
@@ -18,7 +18,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: var(--backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -26,74 +26,74 @@
} }
.load-dialog { .load-dialog {
background: white; background: var(--surface);
border-radius: 8px; border-radius: 8px;
width: 500px; width: 500px;
max-width: 90vw; max-width: 90vw;
max-height: 80vh; max-height: 80vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-overlay);
} }
.load-dialog-header { .load-dialog-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 20px; padding: var(--space-4) var(--space-5);
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--border);
} }
.load-dialog-header h2 { .load-dialog-header h2 {
margin: 0; margin: 0;
font-size: 18px; font-size: var(--fs-xl);
color: #333; color: var(--text);
} }
.load-dialog-close { .load-dialog-close {
background: none; background: none;
border: none; border: none;
font-size: 24px; font-size: var(--fs-2xl);
cursor: pointer; cursor: pointer;
color: #999; color: var(--text-muted);
padding: 0; padding: 0;
line-height: 1; line-height: 1;
} }
.load-dialog-close:hover { .load-dialog-close:hover {
color: #333; color: var(--text);
} }
.load-dialog-actions { .load-dialog-actions {
padding: 12px 20px; padding: var(--space-3) var(--space-5);
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--border);
} }
.load-dialog-new-btn { .load-dialog-new-btn {
padding: 8px 16px; padding: var(--space-2) var(--space-4);
background: #667eea; background: var(--accent);
color: white; color: var(--accent-contrast);
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: var(--fs-md);
} }
.load-dialog-new-btn:hover { .load-dialog-new-btn:hover {
background: #5a6fd6; background: var(--accent-hover);
} }
.load-dialog-content { .load-dialog-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 0; padding: var(--space-3) 0;
} }
.load-dialog-loading, .load-dialog-loading,
.load-dialog-empty { .load-dialog-empty {
padding: 40px 20px; padding: var(--space-10) var(--space-5);
text-align: center; text-align: center;
color: #999; color: var(--text-muted);
} }
.load-dialog-list { .load-dialog-list {
@@ -105,47 +105,47 @@
.load-dialog-item { .load-dialog-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 20px; padding: var(--space-3) var(--space-5);
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--border);
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
.load-dialog-item:hover { .load-dialog-item:hover {
background: #f8f8f8; background: var(--surface-hover);
} }
.load-dialog-item--current { .load-dialog-item--current {
background: #e8f0ff; background: var(--accent-soft);
} }
.load-dialog-item--current:hover { .load-dialog-item--current:hover {
background: #dde8ff; background: color-mix(in srgb, var(--accent) 20%, transparent);
} }
.load-dialog-item-info { .load-dialog-item-info {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: var(--space-1);
} }
.load-dialog-item-name { .load-dialog-item-name {
font-weight: 500; font-weight: var(--fw-medium);
color: #333; color: var(--text);
} }
.load-dialog-item-date { .load-dialog-item-date {
font-size: 12px; font-size: var(--fs-sm);
color: #999; color: var(--text-muted);
} }
.load-dialog-item-delete { .load-dialog-item-delete {
background: none; background: none;
border: none; border: none;
font-size: 16px; font-size: var(--fs-lg);
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: var(--space-1) var(--space-2);
opacity: 0.5; opacity: 0.5;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
@@ -1,54 +1,54 @@
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: var(--space-3) var(--space-4);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--header-bg);
border-bottom: 2px solid rgba(0, 0, 0, 0.1); border-bottom: 2px solid color-mix(in srgb, var(--text) 10%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-raised);
gap: 16px; gap: var(--space-4);
} }
.toolbar-group { .toolbar-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-2);
} }
.toolbar-separator { .toolbar-separator {
width: 1px; width: 1px;
height: 32px; height: 32px;
background: rgba(255, 255, 255, 0.3); background: var(--header-chrome-border);
} }
.toolbar-label { .toolbar-label {
color: white; color: var(--header-text);
font-size: 14px; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
margin-right: 4px; margin-right: var(--space-1);
} }
.toolbar-button { .toolbar-button {
padding: 8px 16px; padding: var(--space-2) var(--space-4);
background: rgba(255, 255, 255, 0.2); background: var(--header-chrome);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid var(--header-chrome-border);
color: white; color: var(--header-text);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.toolbar-button:hover { .toolbar-button:hover {
background: rgba(255, 255, 255, 0.3); background: var(--header-chrome-hover);
transform: translateY(-1px); transform: translateY(-1px);
} }
.toolbar-button.active { .toolbar-button.active {
background: rgba(255, 255, 255, 0.4); background: color-mix(in srgb, var(--header-text) 40%, transparent);
border-color: rgba(255, 255, 255, 0.5); border-color: color-mix(in srgb, var(--header-text) 50%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: var(--shadow-raised);
} }
.toolbar-button:disabled { .toolbar-button:disabled {
@@ -62,21 +62,21 @@
} }
.toolbar-report-name { .toolbar-report-name {
padding: 8px 12px; padding: var(--space-2) var(--space-3);
background: rgba(255, 255, 255, 0.9); background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid var(--header-chrome-border);
border-radius: 6px; border-radius: 6px;
font-size: 14px; font-size: var(--fs-md);
width: 200px; width: 200px;
color: #333; color: var(--text);
} }
.toolbar-report-name::placeholder { .toolbar-report-name::placeholder {
color: #999; color: var(--text-muted);
} }
.toolbar-report-name:focus { .toolbar-report-name:focus {
outline: none; outline: none;
border-color: rgba(255, 255, 255, 0.6); border-color: color-mix(in srgb, var(--header-text) 60%, transparent);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2); box-shadow: 0 0 0 2px var(--header-chrome);
} }
@@ -0,0 +1,146 @@
/* A4 designer canvas — screen only. Print styles arrive in Phase 4. */
.a4-canvas-container {
flex: 1;
overflow: auto;
background: var(--desk);
padding: var(--space-6);
display: flex;
justify-content: center;
align-items: flex-start;
}
.a4-page {
position: relative;
background: #fff;
color: #000;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
flex: none;
}
.a4-page.crosshair {
cursor: crosshair;
}
/* Margin and page-centre overlay — purely visual, never intercepts the mouse */
.a4-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.a4-margin-line {
position: absolute;
border: 0 dashed #59a2e0;
}
.a4-margin-line.vertical {
top: 0;
bottom: 0;
border-left-width: 1px;
}
.a4-margin-line.horizontal {
left: 0;
right: 0;
border-top-width: 1px;
}
.a4-center-line {
position: absolute;
border: 0 dashed #e08db5;
}
.a4-center-line.vertical {
top: 0;
bottom: 0;
border-left-width: 1px;
}
.a4-center-line.horizontal {
left: 0;
right: 0;
border-top-width: 1px;
}
/* Smart alignment guides — shown only while dragging.
A magenta accent, as Figma and PowerPoint use: it reads as UI chrome rather
than as page content, which matters on a page that is otherwise black on
white. Stays 1px at every zoom for the same reason — a guide marks a
position, it does not have a width on the paper. */
.a4-guide-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 20;
}
.a4-guide {
position: absolute;
background: #ff00c8;
}
.a4-guide--v {
top: 0;
bottom: 0;
width: 1px;
}
.a4-guide--h {
left: 0;
right: 0;
height: 1px;
}
/* Centre alignments get a distinct cyan so "centres match" is readable at a
glance from "edges match" — the two look identical otherwise. */
.a4-guide--element-center,
.a4-guide--page-center {
background: #00b8d4;
}
/* Rubber-band selection rectangle */
.a4-selection-rectangle {
position: absolute;
border: 1px dashed var(--accent);
background: var(--accent-soft);
pointer-events: none;
z-index: 15;
}
/* Preview mode — the pages layout() produced, stacked with a visible break.
Same array the print path consumes, which is what makes "preview page count
equals printed page count" structural rather than coincidental. */
.a4-preview-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.a4-preview-page-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.a4-preview-page-break {
width: 100%;
padding: var(--space-1-5) 0 var(--space-4);
text-align: center;
font-family: var(--font-sans);
font-size: var(--fs-xs);
color: var(--desk-text);
border-top: 1px dashed color-mix(in srgb, var(--desk-text) 40%, transparent);
margin-top: var(--space-4);
}
.a4-preview-el {
position: absolute;
box-sizing: border-box;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.2;
}
@@ -0,0 +1,533 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import A4TextField from './elements/A4TextField';
import A4DBTextField from './elements/A4DBTextField';
import A4ExprField from './elements/A4ExprField';
import A4Rect from './elements/A4Rect';
import A4HLine from './elements/A4HLine';
import A4VLine from './elements/A4VLine';
import A4Band from './elements/A4Band';
import { mmToPx, pxToMm, roundMm, getPageSize } from './units';
import { a4IntersectsRect, clampToPage } from './geometry';
import { buildGuideCandidates, resolveSnap, unionRect } from './guides';
import { layout } from './layout';
import { styleFor } from './print/PrintDocument';
import './A4Canvas.css';
/**
* One placed element in preview mode.
*
* Reuses PrintDocument's `styleFor` rather than restating it, then rescales the
* millimetre values to on-screen pixels. Preview and print therefore cannot
* drift apart in how an element LOOKS, just as sharing layout() stops them
* drifting in where it SITS.
*
* mm -> px is a pure multiply by (PX_PER_MM * zoom), so every mm length in the
* style object is converted the same way; pt font sizes convert by 96/72.
*/
function PreviewElement({ el, x, y, text, zoom }) {
const mmStyle = styleFor(el, x, y);
const scaled = {};
for (const [prop, value] of Object.entries(mmStyle)) {
if (typeof value !== 'string') {
scaled[prop] = value;
} else if (value.endsWith('mm')) {
scaled[prop] = `${mmToPx(parseFloat(value), zoom)}px`;
} else if (value.endsWith('pt')) {
scaled[prop] = `${parseFloat(value) * (96 / 72) * zoom}px`;
} else if (value.includes('mm')) {
// Compound values such as "0.3mm solid #000000": scale the mm token only.
// Checked AFTER endsWith('mm') because that is the plain-length case.
scaled[prop] = value.replace(/([\d.]+)mm/g, (_, mm) => `${mmToPx(parseFloat(mm), zoom)}px`);
} else {
scaled[prop] = value;
}
}
return (
<div
className={`a4-preview-el a4-preview-el--${el.type}`}
style={scaled}
>
{text}
</div>
);
}
const DEFAULT_TEXT_WIDTH_MM = 40;
const DEFAULT_TEXT_HEIGHT_MM = 6;
const DEFAULT_RECT_WIDTH_MM = 50;
const DEFAULT_RECT_HEIGHT_MM = 20;
const DEFAULT_LINE_LENGTH_MM = 50;
const DEFAULT_VLINE_LENGTH_MM = 30;
const DEFAULT_THICKNESS_MM = 0.3;
const DEFAULT_BAND_HEIGHT_MM = 20;
/**
* New-element factories, one per tool.
*
* Each returns the element's JSON exactly as it will be stored and saved, so
* this table is the single readable statement of every A4 element's shape.
* `footprint` is what the page clamp uses: for a line that is its thickness on
* the axis it does not run along, which is why it is stated separately rather
* than read off width/height.
*/
const ELEMENT_FACTORIES = {
addText: (pageSetup) => ({
type: 'a4-text',
width: DEFAULT_TEXT_WIDTH_MM,
height: DEFAULT_TEXT_HEIGHT_MM,
content: 'Text',
...fontDefaults(pageSetup)
}),
addDBText: (pageSetup) => ({
type: 'a4-dbtext',
width: DEFAULT_TEXT_WIDTH_MM,
height: DEFAULT_TEXT_HEIGHT_MM,
objectKey: '',
fieldPath: '',
dataType: 'general',
...fontDefaults(pageSetup)
}),
addExpr: (pageSetup) => ({
type: 'a4-expr',
width: DEFAULT_TEXT_WIDTH_MM,
height: DEFAULT_TEXT_HEIGHT_MM,
expression: '',
dataType: 'general',
...fontDefaults(pageSetup)
}),
addRect: () => ({
type: 'a4-rect',
width: DEFAULT_RECT_WIDTH_MM,
height: DEFAULT_RECT_HEIGHT_MM,
borderWidth: DEFAULT_THICKNESS_MM,
borderColor: '#000000',
fillColor: null
}),
addHLine: () => ({
type: 'a4-hline',
width: DEFAULT_LINE_LENGTH_MM,
thickness: DEFAULT_THICKNESS_MM,
color: '#000000'
}),
addVLine: () => ({
type: 'a4-vline',
height: DEFAULT_VLINE_LENGTH_MM,
thickness: DEFAULT_THICKNESS_MM,
color: '#000000'
}),
addBand: (pageSetup) => ({
type: 'a4-band',
// x and width are derived from the page's content area, never user-set —
// see A4Band.jsx for why offering a width control would be a trap.
x: pageSetup.margins?.left || 0,
width: contentWidthOf(pageSetup),
height: DEFAULT_BAND_HEIGHT_MM,
bandType: 'detail',
dataSource: '',
caption: '',
children: [],
parentBandId: null
})
};
/** A band spans the page's content width: page width less both side margins. */
const contentWidthOf = (pageSetup) => {
const size = getPageSize(pageSetup);
const m = pageSetup?.margins || {};
return roundMm(size.w - (m.left || 0) - (m.right || 0));
};
const fontDefaults = (pageSetup) => ({
fontFamily: pageSetup.defaultFont || 'Arial',
fontSize: pageSetup.defaultFontSize || 10,
bold: false,
italic: false,
align: 'left',
color: '#000000'
});
/** Box a type occupies for clamping: lines substitute thickness for the axis
* they do not run along, matching getA4Bounds. */
const footprintOf = (draft) => ({
w: (draft.type === 'a4-vline' ? draft.thickness : draft.width) || 0,
h: (draft.type === 'a4-hline' ? draft.thickness : draft.height) || 0
});
/**
* A4 page canvas — everything positioned in millimetre floats.
*
* Structural reference: the matrix `EditorCanvas.jsx` (click/drag/rubber-band
* patterns). Its grid snapping is deliberately NOT carried over: mouse pixels
* convert straight to mm via pxToMm(px, zoom) and are rounded only to 0.1mm.
* Snapping to margins and neighbouring elements is Phase 3.
*/
function A4Canvas({
elements,
selectedElementIds,
onElementSelect,
onSelectMultiple,
onDeselectAll,
onElementUpdate,
onAddElement,
onElementDelete,
toolMode,
pageSetup,
zoom,
previewMode = false,
reportData = null
}) {
const pageRef = useRef(null);
const [selectionRect, setSelectionRect] = useState(null);
const [isSelecting, setIsSelecting] = useState(false);
const [didDragSelect, setDidDragSelect] = useState(false);
// Smart-guide state. The candidate set is a ref, not state: it is built once
// per drag and read on every mousemove, and rebuilding it per move is the one
// thing that would make dragging laggy (100 elements is ~600 candidates).
const guideCandidatesRef = useRef({ V: [], H: [] });
// Alt suppresses snapping. useElementDrag forwards only pixel deltas, and it
// is shared with the matrix editor, so the modifier is tracked here from the
// window rather than by widening that hook's contract.
const altPressedRef = useRef(false);
const [activeGuides, setActiveGuides] = useState([]);
const pageSize = getPageSize(pageSetup);
const pageWidthPx = mmToPx(pageSize.w, zoom);
const pageHeightPx = mmToPx(pageSize.h, zoom);
/** Mouse event → page-relative millimetres. */
const eventToMm = (e) => {
const rect = pageRef.current.getBoundingClientRect();
return {
xMm: pxToMm(e.clientX - rect.left, zoom),
yMm: pxToMm(e.clientY - rect.top, zoom)
};
};
const handlePageMouseDown = (e) => {
if (toolMode !== 'select') return;
const { xMm, yMm } = eventToMm(e);
setIsSelecting(true);
setDidDragSelect(false);
setSelectionRect({ startX: xMm, startY: yMm, endX: xMm, endY: yMm });
};
const handlePageMouseMove = (e) => {
if (!isSelecting || toolMode !== 'select') return;
const { xMm, yMm } = eventToMm(e);
setSelectionRect(prev => {
if (!prev) return prev;
if (Math.abs(xMm - prev.startX) > 0.5 || Math.abs(yMm - prev.startY) > 0.5) {
setDidDragSelect(true);
}
return { ...prev, endX: xMm, endY: yMm };
});
};
const handlePageClick = (e) => {
const { xMm, yMm } = eventToMm(e);
const factory = ELEMENT_FACTORIES[toolMode];
if (factory) {
const draft = factory(pageSetup);
const { w, h } = footprintOf(draft);
const { x, y } = clampToPage(xMm, yMm, w, h, pageSize.w, pageSize.h);
onAddElement({
// The id prefix mirrors the type so a stray element is identifiable in
// saved JSON without cross-referencing its `type`.
id: `${draft.type}-${Date.now()}`,
x: roundMm(x),
y: roundMm(y),
// AFTER the click position, so a factory that fixes its own x wins.
// A band's x comes from the page margins, so only the click's Y means
// anything — taking its X would drop the band wherever the pointer
// happened to be, and the drag lock would then preserve that mistake.
...draft
});
} else if (toolMode === 'select') {
if (!didDragSelect) {
onDeselectAll();
}
setDidDragSelect(false);
}
};
const handleElementDragStart = useCallback((elementId) => {
// Snapshot every selected element so a multi-drag stays relative to where it
// began rather than accumulating rounding error per mousemove.
const ids = selectedElementIds.includes(elementId) && selectedElementIds.length > 1
? selectedElementIds
: [elementId];
const initial = ids.map(id => {
const el = elements.find(e => e.id === id);
const { w, h } = footprintOf(el);
// A band's x is derived from the page margins, never dragged — see the
// constraint note in A4Band.jsx. Recorded per element so a band caught in
// a mixed multi-selection still holds its x while the rest move freely.
return { id, x: el.x, y: el.y, width: w, height: h, lockX: el.type === 'a4-band' };
});
// Build the guide candidates ONCE, here. Everything being dragged is
// excluded — an element cannot meaningfully align to itself, and including
// it would snap it to its own starting position and never let go.
guideCandidatesRef.current = buildGuideCandidates(elements, ids, pageSetup);
return { elementId, initial };
}, [elements, selectedElementIds, pageSetup]);
const handleElementDrag = useCallback((dragData, deltaX, deltaY) => {
const deltaMmX = pxToMm(deltaX, zoom);
const deltaMmY = pxToMm(deltaY, zoom);
// Snap the UNION of the selection, then apply one delta to every member.
// Snapping each element separately would let them find different candidates
// and tear the group apart mid-drag.
const start = unionRect(dragData.initial);
const draft = { ...start, x: start.x + deltaMmX, y: start.y + deltaMmY };
let snapped = { x: draft.x, y: draft.y, guides: [] };
if (!altPressedRef.current) {
snapped = resolveSnap(draft, guideCandidatesRef.current, zoom);
}
// The correction the snap applied to the union, re-expressed as the total
// delta each member takes from ITS OWN start — so relative offsets hold.
const snapDeltaX = deltaMmX + (snapped.x - draft.x);
const snapDeltaY = deltaMmY + (snapped.y - draft.y);
dragData.initial.forEach(({ id, x, y, width, height, lockX }) => {
const clamped = clampToPage(
x + (lockX ? 0 : snapDeltaX), y + snapDeltaY, width, height, pageSize.w, pageSize.h
);
// A locked element reports only y, so no x write can creep in via rounding.
onElementUpdate(id, lockX
? { y: roundMm(clamped.y) }
: { x: roundMm(clamped.x), y: roundMm(clamped.y) });
});
setActiveGuides(snapped.guides);
// pageSize is rebuilt every render, so depend on its two scalars instead.
}, [zoom, onElementUpdate, pageSize.w, pageSize.h]);
// Guides belong to the drag, so they are cleared on any mouseup — including
// one released outside the page, which useElementDrag also listens for.
useEffect(() => {
const clear = () => setActiveGuides(prev => (prev.length ? [] : prev));
document.addEventListener('mouseup', clear);
return () => document.removeEventListener('mouseup', clear);
}, []);
// Alt is read from the window because useElementDrag (shared with the matrix
// editor) forwards no modifier state. `blur` resets it: alt-tabbing away
// otherwise leaves snapping permanently suppressed, since the keyup lands in
// another window.
useEffect(() => {
const sync = (e) => { altPressedRef.current = e.altKey; };
const reset = () => { altPressedRef.current = false; };
window.addEventListener('keydown', sync);
window.addEventListener('keyup', sync);
window.addEventListener('blur', reset);
return () => {
window.removeEventListener('keydown', sync);
window.removeEventListener('keyup', sync);
window.removeEventListener('blur', reset);
};
}, []);
// Rubber-band selection completes on mouseup anywhere, so a release outside
// the page still resolves the selection instead of leaving it stuck.
useEffect(() => {
if (!isSelecting) return;
const handleGlobalMouseUp = () => {
if (selectionRect) {
const rect = {
minX: Math.min(selectionRect.startX, selectionRect.endX),
maxX: Math.max(selectionRect.startX, selectionRect.endX),
minY: Math.min(selectionRect.startY, selectionRect.endY),
maxY: Math.max(selectionRect.startY, selectionRect.endY)
};
// A click (no drag) must not select everything it happens to touch
if (rect.maxX - rect.minX > 0.5 || rect.maxY - rect.minY > 0.5) {
onSelectMultiple(
elements.filter(el => a4IntersectsRect(el, rect)).map(el => el.id)
);
}
}
setIsSelecting(false);
setSelectionRect(null);
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, [isSelecting, selectionRect, elements, onSelectMultiple]);
const margins = pageSetup.margins || {};
const renderOverlay = () => (
<div className="a4-overlay">
<div className="a4-margin-line vertical" style={{ left: `${mmToPx(margins.left || 0, zoom)}px` }} />
<div className="a4-margin-line vertical" style={{ left: `${mmToPx(pageSize.w - (margins.right || 0), zoom)}px` }} />
<div className="a4-margin-line horizontal" style={{ top: `${mmToPx(margins.top || 0, zoom)}px` }} />
<div className="a4-margin-line horizontal" style={{ top: `${mmToPx(pageSize.h - (margins.bottom || 0), zoom)}px` }} />
<div className="a4-center-line vertical" style={{ left: `${mmToPx(pageSize.w / 2, zoom)}px` }} />
<div className="a4-center-line horizontal" style={{ top: `${mmToPx(pageSize.h / 2, zoom)}px` }} />
</div>
);
/**
* Alignment guides — 1px lines in their own pointer-events:none layer.
*
* Driven by local A4Canvas state so a drag re-renders this overlay rather
* than the element tree. Width/height stay 1px at every zoom: a guide is a
* piece of UI chrome, not page content, so it must not scale with the page.
*/
const renderGuides = () => {
if (!activeGuides.length) return null;
return (
<div className="a4-guide-overlay">
{activeGuides.map(({ axis, pos, kind }) => (
<div
key={`${axis}-${pos}`}
className={`a4-guide a4-guide--${axis} a4-guide--${kind}`}
style={axis === 'v'
? { left: `${mmToPx(pos, zoom)}px` }
: { top: `${mmToPx(pos, zoom)}px` }}
/>
))}
</div>
);
};
const renderSelectionRect = () => {
if (!selectionRect || !isSelecting) return null;
const minX = Math.min(selectionRect.startX, selectionRect.endX);
const minY = Math.min(selectionRect.startY, selectionRect.endY);
return (
<div
className="a4-selection-rectangle"
style={{
left: `${mmToPx(minX, zoom)}px`,
top: `${mmToPx(minY, zoom)}px`,
width: `${mmToPx(Math.abs(selectionRect.endX - selectionRect.startX), zoom)}px`,
height: `${mmToPx(Math.abs(selectionRect.endY - selectionRect.startY), zoom)}px`
}}
/>
);
};
const renderElement = (element) => {
const commonProps = {
element,
isSelected: selectedElementIds.includes(element.id),
isSingleSelection: selectedElementIds.length === 1 && selectedElementIds[0] === element.id,
onSelect: (e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey),
onUpdate: (updates) => onElementUpdate(element.id, updates),
onDelete: () => onElementDelete(element.id),
onDragStart: handleElementDragStart,
onDrag: handleElementDrag,
zoom,
toolMode
};
switch (element.type) {
case 'a4-text':
return <A4TextField key={element.id} {...commonProps} />;
case 'a4-dbtext':
return <A4DBTextField key={element.id} {...commonProps} />;
case 'a4-expr':
return <A4ExprField key={element.id} {...commonProps} />;
case 'a4-rect':
return <A4Rect key={element.id} {...commonProps} />;
case 'a4-hline':
return <A4HLine key={element.id} {...commonProps} />;
case 'a4-vline':
return <A4VLine key={element.id} {...commonProps} />;
case 'a4-band':
// Children render INSIDE the band, so their stored y stays relative to
// it and CSS does the offset — same containment model as the matrix
// editor, and what layout() assumes when it emits `y + child.y`.
return (
<A4Band key={element.id} {...commonProps}>
{(element.children || [])
.map(childId => elements.find(el => el.id === childId))
.filter(Boolean)
.map(renderElement)}
</A4Band>
);
default:
// An unknown type renders nothing rather than throwing, so one bad
// element cannot blank the whole canvas.
return null;
}
};
/** Ids owned by a band — rendered by their parent, not at the top level. */
const childIds = new Set();
elements.forEach(el => {
if (el.type === 'a4-band') (el.children || []).forEach(id => childIds.add(id));
});
// PREVIEW MODE renders layout()'s output — the exact pages array the print
// path consumes. That shared source is what makes "preview page count equals
// printed page count" a structural guarantee rather than a coincidence, so
// preview must never grow its own positioning logic.
if (previewMode) {
const { pages } = layout(elements, reportData, pageSetup);
return (
<div className="a4-canvas-container">
<div className="a4-preview-pages">
{pages.map((pageElements, pageIndex) => (
<div key={pageIndex} className="a4-preview-page-wrapper">
<div
className="a4-page"
style={{ width: `${pageWidthPx}px`, height: `${pageHeightPx}px` }}
>
{pageElements.map(({ el, x, y, text, instanceKey }) => (
<PreviewElement
key={instanceKey ? `${instanceKey}-${el.id}` : el.id}
el={el}
x={x}
y={y}
text={text}
zoom={zoom}
/>
))}
</div>
<div className="a4-preview-page-break">
Page {pageIndex + 1} of {pages.length}
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="a4-canvas-container">
<div
ref={pageRef}
className={`a4-page ${ELEMENT_FACTORIES[toolMode] ? 'crosshair' : ''}`}
style={{ width: `${pageWidthPx}px`, height: `${pageHeightPx}px` }}
onClick={handlePageClick}
onMouseDown={handlePageMouseDown}
onMouseMove={handlePageMouseMove}
>
{renderOverlay()}
{renderSelectionRect()}
{elements.filter(el => !childIds.has(el.id)).map(renderElement)}
{/* Last, so guides paint above the elements they align */}
{renderGuides()}
</div>
</div>
);
}
export default A4Canvas;
@@ -0,0 +1,61 @@
/* A4 inspector — layered on top of the shared ObjectInspector.css shell. */
.a4-inspector .mm-suffix {
flex: 0 0 auto;
margin-left: var(--space-1-5);
color: var(--text-muted);
font-size: var(--fs-xs);
}
.a4-inspector .a4-style-buttons {
flex: 1;
display: flex;
gap: var(--space-1);
}
.a4-inspector .a4-style-btn {
flex: 1;
padding: var(--space-1) 0;
border: 1px solid var(--border-strong);
border-radius: 3px;
background: var(--surface);
cursor: pointer;
font-size: var(--fs-sm);
}
.a4-inspector .a4-style-btn:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
.a4-inspector .a4-style-btn.active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.a4-inspector input[type="color"] {
padding: 1px;
height: 26px;
}
.a4-inspector .a4-expression-input {
width: 100%;
box-sizing: border-box;
margin-bottom: var(--space-1);
font-family: var(--font-mono);
font-size: var(--fs-xs);
resize: vertical;
}
.a4-inspector .inspector-hint {
padding: var(--space-0-5) 0 var(--space-1-5);
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.3;
}
.a4-inspector input[type="checkbox"] {
flex: 0 0 auto;
margin-right: auto;
}
@@ -0,0 +1,549 @@
import React, { useState, useEffect } from 'react';
import { roundMm } from './units';
import { FONT_FAMILIES } from './fonts';
import { useReportData } from '../DataContext';
import { getAvailableObjects, getAvailableFields } from '../utils/dataResolver';
import { getAvailableArrays } from './a4BandRenderer';
import '../ObjectInspector.css';
import './A4Inspector.css';
/**
* NOTE ON DUPLICATION — deliberate, see the plan's "matrix editor is not
* modified" section. The data-binding and expression sections below are adapted
* from `ObjectInspector.jsx` rather than imported from it. That file drives the
* one working report feature and parses geometry with parseInt; A4 needs
* parseFloat millimetres and the `align`/`a4-*` property names. These sections
* WILL drift from the matrix ones over time. That is the accepted price of not
* touching working code.
*/
const DATA_TYPES = [
{ value: 'general', label: 'General' },
{ value: 'number', label: 'Number' },
{ value: 'date', label: 'Date' },
{ value: 'time', label: 'Time' },
{ value: 'datetime', label: 'Date & Time' }
];
const TYPE_LABELS = {
'a4-text': 'Text',
'a4-dbtext': 'Data Field',
'a4-expr': 'Expression',
'a4-rect': 'Rectangle',
'a4-hline': 'Horizontal Line',
'a4-vline': 'Vertical Line'
};
/**
* Millimetre number input with local draft-string state.
*
* This is why it exists: a naive `parseFloat` + `isNaN` guard makes typing "12."
* impossible. The intermediate value parses to 12, re-renders the input as "12",
* and the decimal point the user just typed disappears. So the input owns the
* raw string while focused, commits whenever the draft parses, and re-syncs from
* the prop only when the value changed elsewhere (drag, resize, load).
*/
export function MmInput({ label, value, onCommit, min, step = 0.1 }) {
const [draft, setDraft] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) {
setDraft(String(value ?? ''));
}
}, [value, isFocused]);
const handleChange = (e) => {
const next = e.target.value;
setDraft(next);
// Commit only when the draft is a complete number. "12." and "" stay in the
// draft untouched, so the caret and the typed character survive.
const parsed = parseFloat(next);
if (next.trim() !== '' && !Number.isNaN(parsed) && /^-?\d*\.?\d*$/.test(next.trim())) {
const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
onCommit(roundMm(clamped));
}
};
const handleBlur = () => {
setIsFocused(false);
const parsed = parseFloat(draft);
if (draft.trim() === '' || Number.isNaN(parsed)) {
// Abandoned edit — snap the field back to the element's real value
setDraft(String(value ?? ''));
return;
}
const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
const rounded = roundMm(clamped);
onCommit(rounded);
setDraft(String(rounded));
};
return (
<div className="property-row">
<label>{label}</label>
{/* type="text" + inputMode="decimal", NOT type="number": a number input
sanitises partial values, so the browser throws away the "12." the user
is midway through typing and the decimal point appears not to type. */}
<input
type="text"
inputMode="decimal"
data-step={step}
value={draft}
onFocus={() => setIsFocused(true)}
onChange={handleChange}
onBlur={handleBlur}
/>
<span className="mm-suffix">mm</span>
</div>
);
}
/** Font family / size / style / alignment / colour — shared by all text types. */
function FontSection({ element, onUpdate }) {
return (
<div className="inspector-section">
<div className="section-title">Font</div>
<div className="property-row">
<label>Family</label>
<select
value={element.fontFamily || 'Arial'}
onChange={(e) => onUpdate({ fontFamily: e.target.value })}
>
{FONT_FAMILIES.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div className="property-row">
<label>Size (pt)</label>
{/* pt, not mm: a number input is fine here because the step is 0.5 and
users type whole or half points, never a long decimal tail. */}
<input
type="number"
step="0.5"
min="1"
value={element.fontSize ?? 10}
onChange={(e) => {
const parsed = parseFloat(e.target.value);
if (!Number.isNaN(parsed) && parsed > 0) onUpdate({ fontSize: parsed });
}}
/>
</div>
<div className="property-row">
<label>Style</label>
<div className="a4-style-buttons">
<button
type="button"
className={`a4-style-btn ${element.bold ? 'active' : ''}`}
onClick={() => onUpdate({ bold: !element.bold })}
title="Bold"
>
<b>B</b>
</button>
<button
type="button"
className={`a4-style-btn ${element.italic ? 'active' : ''}`}
onClick={() => onUpdate({ italic: !element.italic })}
title="Italic"
>
<i>I</i>
</button>
</div>
</div>
<div className="property-row">
<label>Align</label>
<select
value={element.align || 'left'}
onChange={(e) => onUpdate({ align: e.target.value })}
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
<div className="property-row">
<label>Colour</label>
<input
type="color"
value={element.color || '#000000'}
onChange={(e) => onUpdate({ color: e.target.value })}
/>
</div>
</div>
);
}
function DataTypeRow({ element, onUpdate }) {
return (
<div className="property-row">
<label>Data Type</label>
<select
value={element.dataType || 'general'}
onChange={(e) => onUpdate({ dataType: e.target.value })}
>
{DATA_TYPES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
);
}
/**
* Object / field pickers driven by the live reportData.
*
* Both are rendered as selects with the stored value present as an option even
* when it is absent from the current data. Otherwise loading a saved report
* before its preview data arrives would show an empty select and silently
* clear the binding on the next change — losing work that was on screen.
*/
function BindingSection({ element, onUpdate, reportData }) {
const objects = getAvailableObjects(reportData);
const fields = element.objectKey && reportData?.[element.objectKey]
? getAvailableFields(reportData[element.objectKey])
: [];
const objectOptions = element.objectKey && !objects.includes(element.objectKey)
? [element.objectKey, ...objects]
: objects;
const fieldOptions = element.fieldPath && !fields.includes(element.fieldPath)
? [element.fieldPath, ...fields]
: fields;
return (
<div className="inspector-section">
<div className="section-title">Data Binding</div>
<div className="property-row">
<label>Object</label>
<select
value={element.objectKey || ''}
onChange={(e) => onUpdate({ objectKey: e.target.value, fieldPath: '' })}
>
<option value="">-- object --</option>
{objectOptions.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div className="property-row">
<label>Field</label>
<select
value={element.fieldPath || ''}
onChange={(e) => onUpdate({ fieldPath: e.target.value })}
disabled={!element.objectKey}
>
<option value="">-- field --</option>
{fieldOptions.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
{objects.length === 0 && (
<div className="inspector-hint">
No preview data loaded — type a binding after fetching data, or load a
report with an API endpoint configured.
</div>
)}
<DataTypeRow element={element} onUpdate={onUpdate} />
</div>
);
}
/**
* Expression source.
*
* NOT `ExpressionEditorPanel.jsx`. That component is coupled to the matrix
* editor in two ways that would misbehave here: it looks up the parent band
* with `el.type === 'band'` (A4 bands are `a4-band`), and its alignment select
* writes `element.alignment`, whereas every A4 element and renderer reads
* `element.align`. Using it would silently produce a second, dead alignment
* property on A4 elements. A plain textarea plus a field-insert picker covers
* the same ground without that coupling.
*/
function ExpressionSection({ element, onUpdate, reportData }) {
const [selectedField, setSelectedField] = useState('');
const availableFields = [];
getAvailableObjects(reportData).forEach(objectKey => {
getAvailableFields(reportData[objectKey])
.forEach(f => availableFields.push(`${objectKey}.${f}`));
});
const insertField = () => {
if (!selectedField) return;
onUpdate({ expression: `${element.expression || ''}[${selectedField}]` });
};
return (
<div className="inspector-section">
<div className="section-title">Expression</div>
<textarea
className="a4-expression-input"
value={element.expression || ''}
onChange={(e) => onUpdate({ expression: e.target.value })}
rows={3}
placeholder="[vehicle.gross] - [vehicle.tare]"
/>
<div className="property-row">
<label>Insert</label>
<select value={selectedField} onChange={(e) => setSelectedField(e.target.value)}>
<option value="">-- field --</option>
{availableFields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div className="property-row">
<button type="button" onClick={insertField} disabled={!selectedField}>
Insert Field
</button>
</div>
<DataTypeRow element={element} onUpdate={onUpdate} />
</div>
);
}
function RectSection({ element, onUpdate }) {
const hasFill = element.fillColor !== null && element.fillColor !== undefined;
return (
<div className="inspector-section">
<div className="section-title">Border &amp; Fill</div>
<MmInput
label="Border"
value={element.borderWidth ?? 0.3}
min={0}
onCommit={(borderWidth) => onUpdate({ borderWidth })}
/>
<div className="property-row">
<label>Border Colour</label>
<input
type="color"
value={element.borderColor || '#000000'}
onChange={(e) => onUpdate({ borderColor: e.target.value })}
/>
</div>
<div className="property-row">
<label>Fill</label>
{/* `fillColor: null` is "no fill" — distinct from white, which would
hide anything the rect is framing. A colour input cannot express
null, so the checkbox owns that state. */}
<input
type="checkbox"
checked={hasFill}
onChange={(e) => onUpdate({ fillColor: e.target.checked ? '#ffffff' : null })}
/>
</div>
{hasFill && (
<div className="property-row">
<label>Fill Colour</label>
<input
type="color"
value={element.fillColor}
onChange={(e) => onUpdate({ fillColor: e.target.value })}
/>
</div>
)}
</div>
);
}
function LineSection({ element, onUpdate }) {
return (
<div className="inspector-section">
<div className="section-title">Line</div>
<MmInput
label="Thickness"
value={element.thickness ?? 0.3}
min={0.1}
onCommit={(thickness) => onUpdate({ thickness })}
/>
<div className="property-row">
<label>Colour</label>
<input
type="color"
value={element.color || '#000000'}
onChange={(e) => onUpdate({ color: e.target.value })}
/>
</div>
</div>
);
}
const BAND_TYPES = [
{ value: 'header', label: 'Header — repeats at the top of every page' },
{ value: 'detail', label: 'Detail — one instance per data row' },
{ value: 'subdetail', label: 'Subdetail — nested inside a detail band' },
{ value: 'summary', label: 'Summary — once, after the detail rows' },
{ value: 'footer', label: 'Footer — repeats at the bottom of every page' }
];
/**
* Band properties.
*
* `dataSource` lists the arrays actually present in the current report data, so
* a user picks a real one rather than typing a path that silently resolves to
* nothing. X and Width are absent by design — a band spans the page's content
* width, derived from the margins (see A4Band.jsx).
*/
function BandSection({ element, onUpdate, reportData, allElements }) {
const arrays = getAvailableArrays(reportData);
const otherBands = (allElements || []).filter(
el => el.type === 'a4-band' && el.id !== element.id
);
return (
<div className="inspector-section">
<div className="section-title">Band</div>
<div className="property-row">
<label>Type</label>
<select
value={element.bandType || 'detail'}
onChange={(e) => onUpdate({ bandType: e.target.value })}
>
{BAND_TYPES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="property-row">
<label>Data source</label>
<select
value={element.dataSource || ''}
onChange={(e) => onUpdate({ dataSource: e.target.value })}
>
<option value="">(none — renders once)</option>
{arrays.map(({ key, path }) => (
<option key={path} value={path}>{key}</option>
))}
</select>
</div>
{element.bandType === 'subdetail' && (
<div className="property-row">
<label>Inside band</label>
<select
value={element.parentBandId || ''}
onChange={(e) => onUpdate({ parentBandId: e.target.value || null })}
>
<option value="">(none)</option>
{otherBands.map(b => (
<option key={b.id} value={b.id}>
{b.caption || b.bandType || b.id}
</option>
))}
</select>
</div>
)}
<div className="property-row">
<label>Caption</label>
<input
type="text"
value={element.caption || ''}
onChange={(e) => onUpdate({ caption: e.target.value })}
placeholder="Shown in the editor only"
/>
</div>
<p className="inspector-hint">
Elements dropped inside a band repeat with it. Anything outside a band
prints on page 1 only — put what should repeat on every page into a
header or footer band.
</p>
</div>
);
}
function A4Inspector({ element, onUpdate, allElements }) {
const { reportData } = useReportData();
if (!element) {
return (
<div className="object-inspector a4-inspector">
<div className="inspector-header">Properties</div>
<div className="inspector-empty">No element selected</div>
</div>
);
}
const { type } = element;
const isTextual = type === 'a4-text' || type === 'a4-dbtext' || type === 'a4-expr';
return (
<div className="object-inspector a4-inspector">
<div className="inspector-header">
{TYPE_LABELS[type] || type} · {element.id}
</div>
<div className="inspector-section">
<div className="section-title">Geometry</div>
{type !== 'a4-band' && (
<MmInput label="X" value={element.x} min={0} onCommit={(x) => onUpdate({ x })} />
)}
<MmInput label="Y" value={element.y} min={0} onCommit={(y) => onUpdate({ y })} />
{/* A line has only the dimension it runs along; offering the other
would write a property its renderer never reads. A band's width and
x come from the page margins, so they are not user-set either. */}
{type !== 'a4-vline' && type !== 'a4-band' && (
<MmInput label="Width" value={element.width} min={1}
onCommit={(width) => onUpdate({ width })} />
)}
{type !== 'a4-hline' && (
<MmInput label="Height" value={element.height} min={1}
onCommit={(height) => onUpdate({ height })} />
)}
</div>
{type === 'a4-text' && (
<div className="inspector-section">
<div className="section-title">Content</div>
<div className="property-row">
<label>Text</label>
<input
type="text"
value={element.content || ''}
onChange={(e) => onUpdate({ content: e.target.value })}
/>
</div>
</div>
)}
{type === 'a4-dbtext' && (
<BindingSection element={element} onUpdate={onUpdate} reportData={reportData} />
)}
{type === 'a4-expr' && (
<ExpressionSection element={element} onUpdate={onUpdate} reportData={reportData} />
)}
{type === 'a4-rect' && <RectSection element={element} onUpdate={onUpdate} />}
{(type === 'a4-hline' || type === 'a4-vline') && (
<LineSection element={element} onUpdate={onUpdate} />
)}
{type === 'a4-band' && (
<BandSection
element={element}
onUpdate={onUpdate}
reportData={reportData}
allElements={allElements}
/>
)}
{isTextual && <FontSection element={element} onUpdate={onUpdate} />}
</div>
);
}
export default A4Inspector;
@@ -0,0 +1,118 @@
/* Kind chooser — sits above the load dialog (z-index 1000 in ReportEditor.css). */
.a4-kind-overlay {
position: fixed;
inset: 0;
background: var(--backdrop);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.a4-kind-dialog {
background: var(--surface);
border-radius: 8px;
width: 460px;
max-width: 92vw;
box-shadow: var(--shadow-overlay);
}
.a4-kind-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.a4-kind-header h2 {
margin: 0;
font-size: var(--fs-lg);
color: var(--text);
}
.a4-kind-close {
border: none;
background: none;
font-size: var(--fs-2xl);
line-height: 1;
color: var(--text-muted);
cursor: pointer;
}
.a4-kind-close:hover {
color: var(--text);
}
.a4-kind-options {
display: flex;
gap: var(--space-3);
padding: var(--space-5);
}
.a4-kind-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1-5);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-sunken);
cursor: pointer;
text-align: left;
font: inherit;
}
.a4-kind-option:hover {
border-color: var(--accent);
background: var(--accent-soft);
}
.a4-kind-option-icon {
font-size: var(--fs-2xl);
}
.a4-kind-option-title {
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--text);
}
.a4-kind-option-desc {
font-size: var(--fs-xs);
line-height: 1.45;
color: var(--text-muted);
}
/* Cross-kind group in the load dialog — dimmed, because these are reports the
current editor cannot open; clicking one navigates to the other editor. */
.load-dialog-group-title {
padding: var(--space-2-5) var(--space-1) var(--space-1);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.load-dialog-item--other {
opacity: 0.6;
}
.load-dialog-item--other:hover {
opacity: 1;
}
.load-dialog-kind-badge {
margin-left: var(--space-2);
padding: 1px var(--space-1-5);
border-radius: 9px;
background: var(--surface-hover);
color: var(--text-muted);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
vertical-align: middle;
}
@@ -0,0 +1,46 @@
import React from 'react';
import './A4KindChooser.css';
/**
* "What kind of report?" — shown by the load dialog's + New Report button.
*
* Two kinds exist and they are not interchangeable: a matrix report is a
* character grid for a dot-matrix printer, an A4 report is millimetres for a
* laser printer or PDF. Picking the wrong one is only discovered after the
* layout has been drawn, so the choice is made up front rather than being a
* setting to change later (there is no converter between the two).
*
* @param {(kind: 'matrix'|'a4') => void} onChoose
*/
function A4KindChooser({ onChoose, onCancel }) {
return (
<div className="a4-kind-overlay" onClick={onCancel}>
<div className="a4-kind-dialog" onClick={e => e.stopPropagation()}>
<div className="a4-kind-header">
<h2>New report</h2>
<button className="a4-kind-close" onClick={onCancel} title="Cancel">×</button>
</div>
<div className="a4-kind-options">
<button className="a4-kind-option" onClick={() => onChoose('matrix')}>
<span className="a4-kind-option-icon">▦</span>
<span className="a4-kind-option-title">Character matrix</span>
<span className="a4-kind-option-desc">
80 × 66 character grid for a dot-matrix printer. Frames and lines
drawn with box characters.
</span>
</button>
<button className="a4-kind-option" onClick={() => onChoose('a4')}>
<span className="a4-kind-option-icon">📄</span>
<span className="a4-kind-option-title">A4 page</span>
<span className="a4-kind-option-desc">
Free millimetre positioning for a laser printer or Save as PDF.
Any installed font.
</span>
</button>
</div>
</div>
</div>
);
}
export default A4KindChooser;
@@ -0,0 +1,66 @@
/* Page setup dialog — a modal, not a docked panel, because it is opened
occasionally and would otherwise compete with the inspector for the left rail. */
.a4-page-setup-overlay {
position: fixed;
inset: 0;
background: var(--backdrop);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.a4-page-setup {
background: var(--surface);
border-radius: 8px;
width: 340px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-overlay);
}
.a4-page-setup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.a4-page-setup-header h2 {
margin: 0;
font-size: var(--fs-lg);
color: var(--text);
}
.a4-page-setup-close {
border: none;
background: none;
font-size: var(--fs-2xl);
line-height: 1;
color: var(--text-muted);
cursor: pointer;
}
.a4-page-setup-close:hover {
color: var(--text);
}
.a4-page-setup-body {
padding: var(--space-2) var(--space-5) var(--space-5);
overflow-y: auto;
}
.a4-page-setup-dims,
.a4-page-setup-hint {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: var(--space-1-5);
line-height: 1.4;
}
.a4-page-setup-dims {
color: var(--accent);
}

Some files were not shown because too many files have changed in this diff Show More