Compare commits

..

13 Commits

Author SHA1 Message Date
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
121 changed files with 17152 additions and 15681 deletions
@@ -8,8 +8,22 @@
- Inspector CSS: `ObjectInspector.css`
- Data resolver utils: `utils/dataResolver.js` — `resolveDBTextValue`, `getAvailableObjects`, `getAvailableFields`
- 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:
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.
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`.
@@ -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`.
+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
+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
+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) ---
# 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
REACT_APP_SERIAL_URL=http://192.168.24.45:8102
VITE_SERIAL_URL=http://192.168.24.45:8102
# --- Serial bridge / test writer ---
BAUD_RATE=9600
+16
View File
@@ -61,3 +61,19 @@ yarn-error.log*
# Claude Code temporary files
tmpclaude-*
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
ScalesApp is a three-tier weighing scale management system with real-time data synchronization:
- **Django Backend** (REST API + Server-Sent Events) on port 8000
- **React Frontend** (SPA) on port 3000
- **Django Backend** (REST API + Server-Sent Events) on port 8002
- **React Frontend** (SPA) on port 5172
- **Serial Bridge** (Flask SSE server) on port 5000 for COM port reading
## Development Commands
@@ -25,8 +25,8 @@ python manage.py migrate
python manage.py createsuperuser
# Run server
python manage.py runserver # Development (WSGI)
uvicorn scalesapp.asgi:application --reload # Development (ASGI - for SSE)
python manage.py runserver 8002 # Development (WSGI)
uvicorn scalesapp.asgi:application --reload --port 8002 # Development (ASGI - for SSE)
# Testing (when implemented)
python manage.py test
@@ -38,11 +38,16 @@ python manage.py test api.tests.test_models # Single test file
```bash
cd frontend
npm install
npm start # Development server
npm run build # Production build
npm test # Run tests
npm run dev # Vite dev server on port 5172 (set in vite.config.mjs)
npm start # Alias for `npm run dev`
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
```bash
@@ -253,7 +258,8 @@ Element (base class)
- SSE connection authenticated via query param: `/sse-connect/?token=<jwt>` or Authorization header
**CORS Configuration:**
- Allowed origins: http://localhost:3000, http://127.0.0.1:3000, http://localhost:5174
- 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)
**User Roles:**
@@ -316,25 +322,26 @@ DB_PORT=5432
**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`
```
COM_PORT=COM1
BAUD_RATE=9600
BACKEND_URL=http://localhost:8000
AUTO_CONNECT=True
DEBUG=False
```
## Running the Full Application
1. Start Django: `cd backend && python manage.py runserver`
1. Start Django: `cd backend && python manage.py runserver 8002`
2. Start Serial Bridge: `cd serial_bridge && python app.py`
3. Start React: `cd frontend && npm start`
For SSE to work properly in Django, use uvicorn instead: `uvicorn scalesapp.asgi:application --reload`
For SSE to work properly in Django, use uvicorn instead: `uvicorn scalesapp.asgi:application --reload --port 8002`
## Common Patterns
+3 -5
View File
@@ -20,9 +20,7 @@ ScalesApp/
├── serial_bridge/ # Python serial reader (system tray)
│ ├── app.py
│ ├── serial_reader.py
│ ├── backend_client.py
│ ├── tray_icon.py
│ ├── config.py
│ ├── requirements.txt
│ ├── serial_bridge.spec
│ └── .env.example
@@ -92,7 +90,7 @@ npm install
#### b. Setup environment variables
```bash
copy .env.example .env
# Ensure REACT_APP_API_URL=http://localhost:8000
# Ensure VITE_API_URL=http://localhost:8000
```
#### c. Start development server
@@ -234,7 +232,7 @@ DATABASE_URL=sqlite:///db.sqlite3
### Frontend (frontend/.env)
```env
REACT_APP_API_URL=http://localhost:8000
VITE_API_URL=http://localhost:8000
```
### Serial Bridge (serial_bridge/.env)
@@ -254,7 +252,7 @@ DEBUG=False
- **Database errors**: Run migrations: `python manage.py migrate`
### 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`
### Serial Bridge Issues
+8
View File
@@ -0,0 +1,8 @@
run_server
run_comport_reader
run_test
cd frontend
npm start
+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):
"""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)
kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX)
page_width = models.IntegerField(default=80)
page_height = models.IntegerField(default=66)
api_endpoint = models.CharField(max_length=500, blank=True, default='')
elements = models.JSONField(default=list)
# 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(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name='reports'
@@ -63,6 +74,80 @@ class Report(models.Model):
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):
year = models.IntegerField(unique=True)
last_number = models.IntegerField(default=0)
+212 -44
View File
@@ -1,8 +1,11 @@
from rest_framework import serializers
from django.contrib.auth.password_validation import validate_password
from .models import ComPortReading, User, Report
from vehicles.models import Vehicle, VehicleExtra
from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry
from .models import ComPortReading, User, Report, ScaleProtocol
from vehicles.models import Vehicle, VehicleExtra, ExtraCombination
from nomenclatures.models import (
Nomenclature, NomenclatureField, NomenclatureEntry, SelectorOption,
)
from nomenclatures.visibility import build_reveal_map, is_visible
class UserSerializer(serializers.ModelSerializer):
@@ -76,6 +79,71 @@ class ComPortReadingSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'timestamp']
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'
or kind='selector' (the projection stored as a reusable combination).
"""
if not value:
return value
nomenclatures = {
n.code: n for n in
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related(
'fields', 'entries', 'selector_options__reveals'
)
}
reveal_map = build_reveal_map()
errors = {}
for key, val in value.items():
if key not in nomenclatures:
errors[key] = f"Unknown nomenclature code '{key}'."
continue
nom = nomenclatures[key]
# 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):
errors[key] = "Must be a nomenclature entry ID (integer)."
elif not nom.entries.filter(id=val, is_active=True).exists():
errors[key] = f"Entry ID {val} not found in '{nom.code}'."
elif nom.kind == Nomenclature.FIELD:
if lookup_only:
errors[key] = "Only lookup and selector nomenclatures are allowed here."
continue
field_def = nom.fields.first()
if field_def:
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
errors[key] = "Must be a string."
elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)):
errors[key] = "Must be a number."
elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool):
errors[key] = "Must be a boolean."
elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []):
errors[key] = f"Must be one of {field_def.choices}."
if errors:
raise serializers.ValidationError(errors)
return value
class VehicleExtraSerializer(serializers.ModelSerializer):
class Meta:
model = VehicleExtra
@@ -83,44 +151,7 @@ class VehicleExtraSerializer(serializers.ModelSerializer):
read_only_fields = ['id']
def validate_data(self, value):
if not value:
return value
nomenclatures = {
n.code: n for n in
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries')
}
errors = {}
for key, val in value.items():
if key not in nomenclatures:
errors[key] = f"Unknown nomenclature code '{key}'."
continue
nom = nomenclatures[key]
if nom.kind == Nomenclature.LOOKUP:
if not isinstance(val, int):
errors[key] = "Must be a nomenclature entry ID (integer)."
elif not nom.entries.filter(id=val, is_active=True).exists():
errors[key] = f"Entry ID {val} not found in '{nom.code}'."
elif nom.kind == Nomenclature.FIELD:
field_def = nom.fields.first()
if field_def:
if field_def.field_type == NomenclatureField.TEXT and not isinstance(val, str):
errors[key] = "Must be a string."
elif field_def.field_type == NomenclatureField.NUMBER and not isinstance(val, (int, float)):
errors[key] = "Must be a number."
elif field_def.field_type == NomenclatureField.BOOL and not isinstance(val, bool):
errors[key] = "Must be a boolean."
elif field_def.field_type == NomenclatureField.CHOICE and val not in (field_def.choices or []):
errors[key] = f"Must be one of {field_def.choices}."
if errors:
raise serializers.ValidationError(errors)
return value
return validate_vehicle_extra_data(value)
class VehicleSerializer(serializers.ModelSerializer):
@@ -133,11 +164,15 @@ class VehicleSerializer(serializers.ModelSerializer):
model = Vehicle
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid',
'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_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
'net', 'net_date', 'net_user', 'net_user_name',
'doc_number', 'extra']
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_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note',
'net', 'net_date', 'net_user', 'net_user_name',
'doc_number']
@@ -174,6 +209,53 @@ class VehicleSerializer(serializers.ModelSerializer):
return value.strip()
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 Meta:
model = NomenclatureField
@@ -255,27 +337,53 @@ class NomenclatureEntrySerializer(serializers.ModelSerializer):
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):
fields = NomenclatureFieldSerializer(many=True, required=False)
selector_options = SelectorOptionSerializer(many=True, required=False)
entry_count = serializers.IntegerField(source='entries.count', read_only=True)
class Meta:
model = Nomenclature
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']
def create(self, validated_data):
fields_data = validated_data.pop('fields', [])
options_data = validated_data.pop('selector_options', [])
nomenclature = Nomenclature.objects.create(**validated_data)
for field_data in fields_data:
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
self._write_options(nomenclature, options_data)
return nomenclature
def update(self, instance, validated_data):
fields_data = validated_data.pop('fields', None)
options_data = validated_data.pop('selector_options', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
@@ -286,8 +394,20 @@ class NomenclatureSerializer(serializers.ModelSerializer):
for field_data in fields_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
@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):
if not value or not value.strip():
raise serializers.ValidationError("Code cannot be empty")
@@ -304,6 +424,14 @@ class NomenclatureSerializer(serializers.ModelSerializer):
def validate(self, data):
kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP))
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:
raise serializers.ValidationError(
@@ -320,14 +448,49 @@ class NomenclatureSerializer(serializers.ModelSerializer):
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):
created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None)
class Meta:
model = Report
fields = ['id', 'name', 'page_width', 'page_height', 'api_endpoint',
'elements', 'created_by', 'created_by_name', 'created_at', 'updated_at']
fields = ['id', 'name', 'kind', 'page_width', 'page_height', 'api_endpoint',
'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']
def validate_name(self, value):
@@ -339,3 +502,8 @@ class ReportSerializer(serializers.ModelSerializer):
if not isinstance(value, list):
raise serializers.ValidationError("Elements must be a list")
return value
def validate_page_setup(self, value):
if not isinstance(value, dict):
raise serializers.ValidationError("page_setup must be an object")
return value
+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'nomenclatures', views.NomenclatureViewSet, basename='nomenclature')
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')
urlpatterns = [
# Licensing
path('license/', views.LicenseView.as_view(), name='license'),
path('license/heartbeat/', views.LicenseHeartbeatView.as_view(), name='license-heartbeat'),
# JWT token endpoints
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
+282 -36
View File
@@ -1,17 +1,21 @@
from rest_framework import viewsets, status
from rest_framework import viewsets, status, mixins
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
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 (
ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
NomenclatureEntrySerializer, ReportSerializer,
NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
ScaleProtocolSerializer,
)
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 scalesapp.sse import sse_broadcast_update
from . import licensing
class IsAdminUser(IsAuthenticated):
@@ -137,6 +141,84 @@ def _maybe_finalize(vehicle, user):
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 a recorded weighing to the other slot, carrying all its provenance."""
for suffix in ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note'):
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
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):
"""
API endpoint for vehicle management.
@@ -148,8 +230,7 @@ class VehicleViewSet(viewsets.ModelViewSet):
partial_update: Partial update of vehicle
destroy: Delete a vehicle (cascades to VehicleExtra)
by_number: Get vehicle by vehicle_number
set_tare: Set tare weight from scale reading
set_gross: Set gross weight from scale reading
measure: Record a weighing (app assigns tare vs gross by weight)
"""
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
serializer_class = VehicleSerializer
@@ -162,11 +243,13 @@ class VehicleViewSet(viewsets.ModelViewSet):
instance = serializer.save()
data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'created', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_update(self, serializer):
instance = serializer.save()
data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'updated', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_destroy(self, instance):
data = VehicleSerializer(instance).data
@@ -193,10 +276,33 @@ class VehicleViewSet(viewsets.ModelViewSet):
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'], url_path='set-tare')
def set_tare(self, request, pk=None):
"""Set tare weight from scale reading"""
vehicle = self.get_object()
@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."""
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')
if value is None:
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
@@ -205,39 +311,179 @@ class VehicleViewSet(viewsets.ModelViewSet):
except (ValueError, TypeError):
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
vehicle.tare = value
vehicle.tare_date = timezone.now()
vehicle.tare_user = request.user
vehicle.save()
_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()
value = request.data.get('value')
if value is None:
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
value = int(value)
except (ValueError, TypeError):
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
vehicle.gross = value
vehicle.gross_date = timezone.now()
vehicle.gross_user = request.user
vehicle.save()
_maybe_finalize(vehicle, request.user)
_assign_weighing(vehicle, value, request.user, manual=manual, note=note or None)
except ValueError as exc:
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'updated', 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):
"""
API endpoint for nomenclature management.
@@ -385,7 +631,7 @@ class ReportViewSet(viewsets.ModelViewSet):
queryset = Report.objects.select_related('created_by').all()
serializer_class = ReportSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['name']
filterset_fields = ['name', 'kind']
search_fields = ['name']
ordering = ['-updated_at']
+8 -2
View File
@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Nomenclature, NomenclatureField, NomenclatureEntry
from .models import Nomenclature, NomenclatureField, NomenclatureEntry, SelectorOption
class NomenclatureFieldInline(admin.TabularInline):
@@ -7,12 +7,18 @@ class NomenclatureFieldInline(admin.TabularInline):
extra = 1
class SelectorOptionInline(admin.TabularInline):
model = SelectorOption
extra = 1
filter_horizontal = ['reveals']
@admin.register(Nomenclature)
class NomenclatureAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order']
list_filter = ['applies_to', 'kind']
search_fields = ['code', 'name']
inlines = [NomenclatureFieldInline]
inlines = [NomenclatureFieldInline, SelectorOptionInline]
@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"
FIELD = "field"
SELECTOR = "selector"
KIND_CHOICES = [
(LOOKUP, "Lookup Table"),
(FIELD, "Custom Field"),
(SELECTOR, "Selector"),
]
code = models.CharField(max_length=50, unique=True)
@@ -35,6 +37,31 @@ class Nomenclature(models.Model):
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):
TEXT = "text"
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-simplejwt==5.5.1
django-cors-headers==4.6.0
django-filter==26.1
python-dotenv==1.0.1
requests==2.32.3
sqlparse==0.5.3
@@ -9,3 +10,4 @@ psycopg==3.3.2
psycopg-binary==3.3.2
uvicorn[standard]==0.34.0
whitenoise==6.9.0
cryptography==50.0.1
+3 -1
View File
@@ -32,6 +32,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
'rest_framework_simplejwt',
'corsheaders',
'django_filters',
] + PROJECT_APPS
MIDDLEWARE = [
@@ -105,7 +106,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'api.User'
# 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_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = [
@@ -139,6 +140,7 @@ REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'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,
),
),
]
+33
View File
@@ -1,3 +1,6 @@
import hashlib
import json
from typing import Required
from django.db import models
@@ -14,11 +17,17 @@ class Vehicle(models.Model):
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_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_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_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_date = models.DateTimeField(null=True, blank=True)
@@ -36,3 +45,27 @@ class VehicleExtra(models.Model):
Vehicle, on_delete=models.CASCADE, related_name="extra"
)
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})"
+2 -2
View File
@@ -38,8 +38,8 @@ services:
build:
context: ./frontend
args:
REACT_APP_API_URL: ${REACT_APP_API_URL}
REACT_APP_SERIAL_URL: ${REACT_APP_SERIAL_URL}
VITE_API_URL: ${VITE_API_URL}
VITE_SERIAL_URL: ${VITE_SERIAL_URL}
restart: unless-stopped
ports:
- "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 . .
# These are baked into the JS bundle at build time
ARG REACT_APP_API_URL
ARG REACT_APP_SERIAL_URL
ENV REACT_APP_API_URL=$REACT_APP_API_URL
ENV REACT_APP_SERIAL_URL=$REACT_APP_SERIAL_URL
ARG VITE_API_URL
ARG VITE_SERIAL_URL
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SERIAL_URL=$VITE_SERIAL_URL
RUN npm run build
@@ -13,5 +13,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+1390 -15243
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -3,23 +3,18 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"axios": "^1.6.2",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
"start": "vite",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"browserslist": {
"production": [
@@ -33,5 +28,11 @@
"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"
}
}
+21 -6
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
// import ProtectedRoute from './components/ProtectedRoute';
@@ -9,6 +9,12 @@ import UserManager from './components/Users/UserManager';
import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
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'));
// function MainApp() {
// const [selectedPort, setSelectedPort] = useState(null);
@@ -58,11 +64,20 @@ function AppContent() {
return (
<NomenclatureProvider>
<NomenclatureDataProvider>
<Routes>
<Route path="/" element={<Main />} />
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
</Routes>
<ScaleProtocolProvider>
<LicenseProvider>
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
<Routes>
<Route path="/" element={<Main />} />
<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="/" />} />
<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="/" />} />
</Routes>
</Suspense>
</LicenseProvider>
</ScaleProtocolProvider>
</NomenclatureDataProvider>
</NomenclatureProvider>
);
@@ -0,0 +1,129 @@
.extra-combos {
margin-bottom: 16px;
}
.extra-combos h3 {
margin: 0 0 8px 0;
font-size: 12px;
color: #888;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.extra-combos-error {
padding: 6px 10px;
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
border-radius: 6px;
font-size: 12px;
margin-bottom: 8px;
}
.extra-combos-table-container {
max-height: 180px;
overflow-y: auto;
overflow-x: auto;
border: 1px solid #e0e0e0;
border-radius: 6px;
}
.extra-combos-table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.extra-combos-table-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
.extra-combos-table {
width: 100%;
border-collapse: collapse;
}
.extra-combos-table thead {
background-color: #f8f9fa;
position: sticky;
top: 0;
z-index: 1;
}
.extra-combos-table th {
padding: 0.4rem 0.75rem;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.extra-combos-th-actions {
width: 36px;
}
.extra-combos-table td {
padding: 0.4rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
white-space: nowrap;
}
.extra-combos-row {
cursor: pointer;
transition: background-color 0.15s;
}
.extra-combos-row:hover {
background-color: #f0f0ff;
}
.extra-combos-td-actions {
text-align: center;
}
.extra-combos-delete-btn {
background: none;
border: 1px solid #dee2e6;
padding: 0 0.35rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
line-height: 1.4;
color: #999;
}
.extra-combos-delete-btn:hover {
background-color: #dc3545;
border-color: #dc3545;
color: white;
}
.extra-combos-stale {
color: #999;
font-style: italic;
}
.extra-combos-empty {
text-align: center;
padding: 1rem !important;
color: #999;
font-style: italic;
font-size: 0.85rem;
}
@@ -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>
);
}
+59
View File
@@ -50,6 +50,65 @@
transform: scale(1.05);
}
/* Header dropdown — the report editor button opens a choice of editors rather
than navigating straight to one, since the two are not interchangeable. */
.nav-dropdown {
position: relative;
display: flex;
}
/* Over the page, under the menu: any outside click closes it. */
.nav-dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 100;
}
.nav-dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 101;
min-width: 220px;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.nav-dropdown-menu button {
display: block;
width: 100%;
padding: 10px 14px;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: #333;
font-family: inherit;
}
.nav-dropdown-menu button + button {
border-top: 1px solid #eee;
}
.nav-dropdown-menu button:hover {
background: #f2f3fa;
}
.nav-dropdown-menu strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.nav-dropdown-menu span {
display: block;
margin-top: 2px;
font-size: 11px;
color: #777;
}
.header-right {
display: flex;
align-items: center;
+54 -7
View File
@@ -10,6 +10,12 @@ function Header() {
const navigate = useNavigate();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const [showEditorMenu, setShowEditorMenu] = useState(false);
const openEditor = (route) => {
setShowEditorMenu(false);
navigate(route);
};
const getInitials = (user) => {
if (user.first_name && user.last_name) {
@@ -28,13 +34,36 @@ function 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>
<div className="nav-dropdown">
<button
className="nav-button"
onClick={() => setShowEditorMenu(v => !v)}
title="Report Editor"
aria-haspopup="menu"
aria-expanded={showEditorMenu}
>
📝
</button>
{showEditorMenu && (
<>
{/* Click-away close, without a document-level listener. */}
<div
className="nav-dropdown-backdrop"
onClick={() => setShowEditorMenu(false)}
/>
<div className="nav-dropdown-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 && (
<button
@@ -54,6 +83,24 @@ function Header() {
👥
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/scale-monitor')}
title="Scale Monitor"
>
📡
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/license')}
title="Licence"
>
🔑
</button>
)}
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
@@ -0,0 +1,266 @@
.license-manager {
min-height: 100vh;
background-color: #f5f5f7;
}
.lm-body {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.lm-loading,
.lm-empty {
padding: 24px;
text-align: center;
color: #999;
font-style: italic;
}
.lm-toolbar {
margin-bottom: 16px;
}
.lm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
cursor: pointer;
font-size: 13px;
}
.lm-back:hover {
border-color: #667eea;
color: #667eea;
}
.lm-ok,
.lm-error {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 12px;
}
.lm-ok {
background-color: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
}
.lm-error {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
}
.lm-panel {
background: white;
border-radius: 8px;
padding: 18px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.lm-panel h3 {
margin: 0 0 12px 0;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lm-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.lm-badge--ok {
background-color: #e8f5e9;
color: #2e7d32;
}
.lm-badge--warn {
background-color: #fff3cd;
color: #856404;
}
.lm-facts {
display: grid;
grid-template-columns: 140px 1fr;
gap: 6px 12px;
margin: 0;
}
.lm-facts dt {
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: center;
}
.lm-facts dd {
margin: 0;
font-size: 14px;
color: #333;
}
.lm-reason {
margin: 0 0 8px 0;
font-size: 14px;
color: #333;
}
.lm-grace {
margin: 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.lm-hint {
margin: 0 0 12px 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.lm-fingerprint {
display: flex;
align-items: center;
gap: 8px;
}
.lm-fingerprint code {
flex: 1;
padding: 10px 12px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 18px;
letter-spacing: 1px;
color: #333;
word-break: break-all;
}
.lm-components {
font-size: 11px !important;
letter-spacing: 0 !important;
}
.lm-fingerprint button {
padding: 8px 14px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
.lm-fingerprint button:hover {
border-color: #667eea;
color: #667eea;
}
.lm-details {
margin-top: 10px;
}
.lm-details summary {
font-size: 12px;
color: #888;
cursor: pointer;
margin-bottom: 8px;
}
.lm-blob {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
box-sizing: border-box;
resize: vertical;
}
.lm-install {
margin-top: 10px;
padding: 9px 18px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.lm-install:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.lm-install:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lm-table {
width: 100%;
border-collapse: collapse;
}
.lm-table th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
text-transform: uppercase;
letter-spacing: 0.3px;
background-color: #f8f9fa;
}
.lm-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
font-family: 'Courier New', monospace;
}
/* Persistent, non-dismissible banner shown app-wide during grace */
.license-banner {
padding: 8px 16px;
background-color: #fff3cd;
border-bottom: 1px solid #ffe69c;
color: #856404;
font-size: 13px;
text-align: center;
}
.license-banner strong {
font-weight: 600;
}
.license-banner--blocked {
background-color: #fee;
border-bottom-color: #fcc;
color: #c33;
}
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
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">
<Header />
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
</div>
);
}
const componentsJson = JSON.stringify(status.components || {});
return (
<div className="license-manager">
<Header />
<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>
);
}
+189
View File
@@ -240,6 +240,87 @@
cursor: not-allowed;
}
/* Design Notice split button — main action plus a caret that opens an explicit
editor choice. The two halves read as one control, so they share the gradient
and only a hairline divides them. */
.design-notice-split {
position: relative;
display: flex;
gap: 1px;
margin-top: 6px;
}
/* .weight-set-btn is width:100%; inside the split the halves are sized by flex. */
.design-notice-split .weight-set-btn {
width: auto;
margin-top: 0;
}
.design-notice-main {
flex: 1;
border-radius: 6px 0 0 6px;
}
.design-notice-caret {
flex: 0 0 28px;
padding: 8px 0;
border-radius: 0 6px 6px 0;
}
/* Sits under the menu and over everything else, so a click anywhere outside
closes it without needing a document-level listener. */
.design-notice-backdrop {
position: fixed;
inset: 0;
z-index: 20;
}
.design-notice-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
z-index: 21;
background: #fff;
border: 1px solid #d8d8e0;
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.design-notice-menu button {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: 12px;
color: #333;
}
.design-notice-menu button + button {
border-top: 1px solid #eee;
}
.design-notice-menu button:hover {
background: #f2f3fa;
}
.design-notice-menu strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.design-notice-menu span {
display: block;
margin-top: 1px;
font-size: 11px;
color: #777;
}
/* Extra fields section */
.extra-section {
border-top: 1px solid #e0e0e0;
@@ -374,3 +455,111 @@
grid-template-columns: 1fr;
}
}
.weight-manual-tag {
margin-left: 6px;
padding: 1px 5px;
border-radius: 3px;
background-color: #fff3cd;
color: #856404;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.weight-manual-btn {
margin-top: 6px;
padding: 4px 10px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.weight-manual-btn:hover {
border-color: #667eea;
color: #667eea;
}
.manual-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.manual-content {
background: white;
border-radius: 12px;
padding: 24px;
width: 340px;
max-width: calc(100% - 32px);
}
.manual-content h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: #333;
}
.manual-field {
margin-bottom: 12px;
}
.manual-field label {
display: block;
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.manual-field input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
.manual-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.reading-value--balanced {
color: #2e7d32;
}
.reading-value--unbalanced {
color: #c62828;
}
.reading-error {
margin-top: 4px;
font-size: 11px;
color: #c62828;
font-style: italic;
}
.weight-license-block {
margin-top: 6px;
font-size: 11px;
color: #c62828;
font-style: italic;
}
+459 -89
View File
@@ -2,17 +2,27 @@ import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatures } from '../contexts/NomenclatureContext';
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 ReportPreviewModal from './ReportPreviewModal';
import ExtraCombinationsTable from './ExtraCombinationsTable';
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
import api from '../services/api';
import './Main.css';
import './License/LicenseManager.css';
export default function Main() {
const navigate = useNavigate();
const { readings, isConnected } = useSerialData();
const { vehicles } = useNomenclatures();
const { currentUser } = useAuth();
const { selectedProtocol } = useScaleProtocols();
const { allowed: licenseAllowed, detail: licenseDetail, stationKey, status: licenseStatus } = useLicense();
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
@@ -22,6 +32,10 @@ export default function Main() {
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [showDesignMenu, setShowDesignMenu] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
// Nomenclature data from context
const { definitions, entries } = useNomenclatureData();
@@ -33,19 +47,76 @@ export default function Main() {
);
const [extraData, setExtraData] = useState({});
// Current reading from COM port
// Data format: "Value: 36106.04" or just "36106.04"
const currentReading = readings.length > 0 ? readings[0].data : null;
const currentWeight = useMemo(() => {
if (!currentReading) return null;
// Extract numeric value - handle "Value: 123.45" or just "123.45"
const match = currentReading.match(/[\d.]+/);
if (match) {
const val = parseFloat(match[0]);
return isNaN(val) ? null : Math.round(val);
}
return null;
}, [currentReading]);
// A selector's value decides which other nomenclatures appear, so the visible
// set is derived per panel from that panel's own extra data.
const visibleForSelected = useMemo(
() => visibleNomenclatures(vehicleNomenclatures, definitions, extraData),
[vehicleNomenclatures, definitions, extraData]
);
const visibleForNew = useMemo(
() => visibleNomenclatures(vehicleNomenclatures, definitions, newExtraData),
[vehicleNomenclatures, definitions, newExtraData]
);
// 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];
}
}
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;
// 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)
const selectedVehicle = useMemo(
@@ -99,9 +170,10 @@ export default function Main() {
driver_pid: newForm.driver_pid.trim() || null,
};
// 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) {
payload.extra = { data: newExtraData };
payload.extra = { data: visibleNewExtra };
}
const res = await api.post('/api/vehicles/', payload);
setSelectedVehicleId(res.data.id);
@@ -117,25 +189,47 @@ export default function Main() {
}
};
// Set tare
const handleSetTare = async () => {
// Record a weighing - the backend decides whether it is tare or gross
const handleMeasure = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/set-tare/`, { value: currentWeight });
} catch {
setError('Failed to set tare');
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
value: currentWeight,
station_key: stationKey,
});
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
}
};
// Set gross
const handleSetGross = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
// 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}/set-gross/`, { value: currentWeight });
} catch {
setError('Failed to set gross');
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 +240,7 @@ export default function Main() {
setError('');
try {
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
extra: { data: extraData },
extra: { data: prunedExtra(extraData, visibleForSelected) },
});
} catch (err) {
const data = err.response?.data;
@@ -156,6 +250,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
const formatDate = (dateStr) => {
if (!dateStr) return '';
@@ -168,6 +282,32 @@ export default function Main() {
? selectedVehicle.gross - selectedVehicle.tare
: null;
const canMeasure = !!currentUser?.can_measure;
const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
// The single recorded weighing, while its tare/gross role is still undecided.
// The backend parks a lone reading in tare, but a lighter second reading can move it to gross.
const firstWeighing = useMemo(() => {
if (!selectedVehicle || isFinalized) return null;
if (selectedVehicle.tare != null) {
return {
value: selectedVehicle.tare,
date: selectedVehicle.tare_date,
userName: selectedVehicle.tare_user_name,
manual: selectedVehicle.tare_manual,
};
}
if (selectedVehicle.gross != null) {
return {
value: selectedVehicle.gross,
date: selectedVehicle.gross_date,
userName: selectedVehicle.gross_user_name,
manual: selectedVehicle.gross_manual,
};
}
return null;
}, [selectedVehicle, isFinalized]);
// Vehicle data shaped for report preview/design
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
const vehicleReportData = useMemo(() => {
@@ -193,9 +333,113 @@ export default function Main() {
};
}, [selectedVehicle, netWeight, definitions, entries]);
// 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 is kind-aware: an A4 report goes straight to the browser
// print dialog (where "Save as PDF" is a destination), a matrix report opens
// the existing on-screen modal exactly as before.
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;
}
if (report.kind === 'a4') {
// Lazy so Main.jsx does not pull the A4 print tree into the
// main bundle for operators who never print an A4 notice.
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
await printA4Report(report, vehicleReportData);
} else {
setShowNoticeReport(true);
}
} catch (err) {
console.error('Failed to print notice:', err);
setError('Failed to load the notice report');
} finally {
setIsPrinting(false);
}
};
// Design Notice routes to the editor that can actually edit the saved
// report. A matrix report opened in the mm editor would read its character
// coordinates as millimetres, so the kind decides the route, not a default.
const handleDesignNotice = async () => {
const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
try {
const report = await fetchNoticeReport();
navigate(report?.kind === 'a4' ? '/report-editor-a4' : '/report-editor', { state });
} catch (err) {
// Nothing saved yet, or the lookup failed — the matrix editor is the
// existing default and pre-fills the name so Save creates it.
console.error('Failed to resolve notice report kind:', err);
navigate('/report-editor', { state });
}
};
/**
* Open a chosen editor regardless of what is saved.
*
* The automatic route above is right for editing an existing notice; this is
* for deliberately starting the OTHER kind. Opening a report in the wrong
* editor reinterprets its coordinates — character cells as millimetres, or
* the reverse — so a mismatch is confirmed rather than done silently.
*/
const handleDesignAs = async (kind) => {
setShowDesignMenu(false);
const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor';
let saved = null;
try {
saved = await fetchNoticeReport();
} catch (err) {
console.error('Failed to resolve notice report kind:', err);
}
if (saved && saved.kind !== kind) {
const savedLabel = saved.kind === 'a4' ? 'A4' : 'matrix';
const targetLabel = kind === 'a4' ? 'A4' : 'matrix';
const ok = window.confirm(
`"notice_report" is saved as a ${savedLabel} report.\n\n` +
`Opening it in the ${targetLabel} editor will read its positions in the ` +
`wrong units, and saving would overwrite the ${savedLabel} layout.\n\n` +
`Open the ${targetLabel} editor anyway?`
);
if (!ok) return;
}
navigate(route, { state });
};
return (
<div>
<Header />
{licenseStatus && !licenseStatus.valid && (
<div className={`license-banner ${licenseStatus.in_grace ? '' : 'license-banner--blocked'}`}>
{licenseStatus.in_grace
? <>This installation is not licensed. Everything keeps working for <strong>{licenseStatus.grace_days_left} more days</strong>.</>
: <>This installation is not licensed. Only {licenseStatus.stations} station can measure.</>}
</div>
)}
<div className="main">
{/* Left panel - Vehicle list */}
<div className="main-left">
@@ -287,16 +531,17 @@ export default function Main() {
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombinationNew}
/>
<div className="extra-fields">
{vehicleNomenclatures.map(nom => (
{visibleForNew.map(nom => (
<div key={nom.code} className="extra-field">
<NomenclatureDropdown
nomenclatureCode={nom.code}
value={newExtraData[nom.code]}
onChange={val => setNewExtraData(prev => ({
...prev,
[nom.code]: val,
}))}
onChange={val => handleNewExtraChange(nom.code, val)}
disabled={isSaving}
/>
</div>
@@ -335,57 +580,101 @@ export default function Main() {
{/* Current reading */}
<div className="reading-display">
<label>Current Reading</label>
<div className={`reading-value ${isConnected ? '' : 'reading-value--disconnected'}`}>
<div className={`reading-value ${
!isConnected
? 'reading-value--disconnected'
: currentWeight === null || isNaN(currentWeight)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{currentWeight !== null && !isNaN(currentWeight)
? `${currentWeight} kg`
: (isConnected ? 'Waiting...' : 'Disconnected')
}
</div>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
</div>
)}
</div>
{/* Weighing section */}
<div className="weighing-section">
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'}
</div>
{selectedVehicle.tare_date && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.tare_date)}
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
{/* Before both weights exist, the first reading's role is not
final yet - show it neutrally so it doesn't appear to jump. */}
{selectedVehicle.tare == null || selectedVehicle.gross == null ? (
<div className="weight-card">
<div className="weight-card-header">First weighing</div>
<div className="weight-card-value">
{firstWeighing ? `${firstWeighing.value} kg` : '---'}
</div>
{firstWeighing && (
<div className="weight-card-meta">
{formatDate(firstWeighing.date)}
{firstWeighing.userName && ` (${firstWeighing.userName})`}
{firstWeighing.manual && <span className="weight-manual-tag">manual</span>}
</div>
)}
</div>
) : (
<>
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">{selectedVehicle.tare} kg</div>
<div className="weight-card-meta">
{formatDate(selectedVehicle.tare_date)}
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
{selectedVehicle.tare_manual && <span className="weight-manual-tag">manual</span>}
</div>
</div>
)}
<button
className="weight-set-btn"
onClick={handleSetTare}
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.tare != null}
>
Set Tare
</button>
</div>
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'}
</div>
{selectedVehicle.gross_date && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.gross_date)}
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
<div className="weight-card-meta">
{formatDate(selectedVehicle.gross_date)}
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
{selectedVehicle.gross_manual && <span className="weight-manual-tag">manual</span>}
</div>
</div>
)}
<button
className="weight-set-btn"
onClick={handleSetGross}
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.gross != null}
>
Set Gross
</button>
</div>
</>
)}
{/* Measure */}
{canMeasure && !isFinalized && (
<div className="weight-card">
<div className="weight-card-header">Scale</div>
<div className={`weight-card-value ${
currentWeight === null || isNaN(currentWeight)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{currentWeight !== null && !isNaN(currentWeight) ? `${currentWeight} kg` : '---'}
</div>
<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>
)}
{/* Net */}
{netWeight !== null && (
@@ -394,21 +683,59 @@ export default function Main() {
<div className="weight-card-value">{netWeight} kg</div>
<button
className="weight-set-btn"
onClick={() => setShowNoticeReport(true)}
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
Print Notice
</button>
<button
className="weight-set-btn"
onClick={() => navigate('/report-editor', {
state: {
reportName: 'notice_report',
vehicleData: vehicleReportData,
}
})}
>
Design Notice
{isPrinting ? 'Preparing...' : 'Print Notice'}
</button>
{/* Split button: the main action routes by
the saved report's kind; the caret opens
an explicit choice for starting the other
kind. */}
<div className="design-notice-split">
<button
className="weight-set-btn design-notice-main"
onClick={handleDesignNotice}
>
Design Notice
</button>
<button
className="weight-set-btn design-notice-caret"
onClick={() => setShowDesignMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showDesignMenu}
title="Choose which editor to open"
>
▾
</button>
{showDesignMenu && (
<>
{/* Click-away closes the menu. */}
<div
className="design-notice-backdrop"
onClick={() => setShowDesignMenu(false)}
/>
<div className="design-notice-menu" role="menu">
<button
role="menuitem"
onClick={() => handleDesignAs('matrix')}
>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button
role="menuitem"
onClick={() => handleDesignAs('a4')}
>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
@@ -417,16 +744,17 @@ export default function Main() {
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<div className="extra-fields">
{vehicleNomenclatures.map(nom => (
{visibleForSelected.map(nom => (
<div key={nom.code} className="extra-field">
<NomenclatureDropdown
nomenclatureCode={nom.code}
value={extraData[nom.code]}
onChange={val => setExtraData(prev => ({
...prev,
[nom.code]: val,
}))}
onChange={val => handleExtraChange(nom.code, val)}
/>
</div>
))}
@@ -450,6 +778,48 @@ export default function Main() {
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>
);
}
@@ -121,6 +121,7 @@
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background 0.15s;
user-select: none;
}
.nm-list-item:hover {
@@ -132,6 +133,15 @@
border-left: 3px solid #667eea;
}
.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 #667eea;
}
.nm-list-item-info {
flex: 1;
min-width: 0;
@@ -614,3 +624,78 @@
grid-template-columns: 1fr;
}
}
/* --- Selector options editor --- */
.nm-options-empty {
margin: 0;
padding: 10px 8px;
font-size: 13px;
color: #888;
line-height: 1.5;
}
.nm-option-row {
border-top: 1px solid #f0f0f0;
padding: 8px;
}
.nm-option-head {
display: grid;
grid-template-columns: 1fr 1fr 32px;
gap: 6px;
align-items: center;
}
.nm-option-head input[type="text"] {
padding: 5px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.nm-option-head input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.nm-option-remove {
background: none;
border: none;
color: #c0392b;
font-size: 18px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.nm-option-remove:disabled {
color: #ccc;
cursor: not-allowed;
}
.nm-option-reveals {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 6px;
padding-left: 2px;
}
.nm-option-reveals-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nm-option-reveal {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #333;
cursor: pointer;
}
@@ -11,6 +11,7 @@ const EMPTY_FORM = {
display_field: 'name',
sort_order: 0,
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
selector_options: [],
};
function NomenclatureManager({ onClose }) {
@@ -22,6 +23,8 @@ function NomenclatureManager({ onClose }) {
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [dragIndex, setDragIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
// Load nomenclature list
const loadNomenclatures = useCallback(async () => {
@@ -59,6 +62,12 @@ function NomenclatureManager({ onClose }) {
required: f.required || false,
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 = () => {
setSelectedId(null);
setError('');
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }] });
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }], selector_options: [] });
};
// Cancel editing
@@ -96,6 +105,14 @@ function NomenclatureManager({ onClose }) {
required: f.required,
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 {
@@ -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 ---
const addField = () => {
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) => {
if (e.target.className === 'nm-overlay') {
onClose();
@@ -187,16 +317,26 @@ function NomenclatureManager({ onClose }) {
{isLoading && <div className="nm-loading">Loading...</div>}
<div className="nm-list">
{nomenclatures.map(nom => (
{nomenclatures.map((nom, idx) => (
<div
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)}
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">
<span className="nm-list-item-name">{nom.name}</span>
<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>
</div>
{canEdit && (
@@ -276,6 +416,7 @@ function NomenclatureManager({ onClose }) {
>
<option value="lookup">Lookup Table</option>
<option value="field">Custom Field</option>
<option value="selector">Selector</option>
</select>
</div>
@@ -295,7 +436,74 @@ function NomenclatureManager({ onClose }) {
)}
</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 */}
{form.kind !== 'selector' && (
<div className="nm-section">
<div className="nm-section-header">
<h3>Fields</h3>
@@ -358,6 +566,7 @@ function NomenclatureManager({ onClose }) {
))}
</div>
</div>
)}
{/* Actions */}
<div className="nm-form-actions">
@@ -35,6 +35,33 @@ export default function NomenclatureDropdown({
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
if (nomenclature.kind === 'lookup') {
const handleSelectChange = (e) => {
@@ -350,6 +350,12 @@ function EditorCanvas({
// Only update if position actually changed
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 (selectedElementIds.includes(dragData.elementId) && selectedElementIds.length > 1) {
const deltaCol = col - element.x;
@@ -357,11 +363,11 @@ function EditorCanvas({
selectedElementIds.forEach(id => {
const el = elements.find(e => e.id === id);
if (el) {
onElementUpdate(id, { x: el.x + deltaCol, y: el.y + deltaRow });
move(el, el.x + deltaCol, el.y + deltaRow);
}
});
} else {
onElementUpdate(dragData.elementId, { x: col, y: row });
move(element, col, row);
}
}
};
@@ -0,0 +1,145 @@
/* A4 designer canvas — screen only. Print styles arrive in Phase 4. */
.a4-canvas-container {
flex: 1;
overflow: auto;
background: #8a8a8a;
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.a4-page {
position: relative;
background: #fff;
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 #667eea;
background: rgba(102, 126, 234, 0.12);
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: 6px 0 14px;
text-align: center;
font-family: system-ui, sans-serif;
font-size: 11px;
color: #e8e8e8;
border-top: 1px dashed rgba(255, 255, 255, 0.4);
margin-top: 14px;
}
.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: 6px;
color: #999;
font-size: 11px;
}
.a4-inspector .a4-style-buttons {
flex: 1;
display: flex;
gap: 4px;
}
.a4-inspector .a4-style-btn {
flex: 1;
padding: 4px 0;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
.a4-inspector .a4-style-btn:hover {
background: #e8eeff;
border-color: #667eea;
}
.a4-inspector .a4-style-btn.active {
background: #667eea;
border-color: #667eea;
color: #fff;
}
.a4-inspector input[type="color"] {
padding: 1px;
height: 26px;
}
.a4-inspector .a4-expression-input {
width: 100%;
box-sizing: border-box;
margin-bottom: 4px;
font-family: monospace;
font-size: 11px;
resize: vertical;
}
.a4-inspector .inspector-hint {
padding: 2px 0 6px;
color: #999;
font-size: 10px;
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: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.a4-kind-dialog {
background: #fff;
border-radius: 8px;
width: 460px;
max-width: 92vw;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.a4-kind-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e0e0e0;
}
.a4-kind-header h2 {
margin: 0;
font-size: 16px;
color: #333;
}
.a4-kind-close {
border: none;
background: none;
font-size: 22px;
line-height: 1;
color: #888;
cursor: pointer;
}
.a4-kind-close:hover {
color: #333;
}
.a4-kind-options {
display: flex;
gap: 12px;
padding: 18px;
}
.a4-kind-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fafafa;
cursor: pointer;
text-align: left;
font: inherit;
}
.a4-kind-option:hover {
border-color: #667eea;
background: #f0f3ff;
}
.a4-kind-option-icon {
font-size: 22px;
}
.a4-kind-option-title {
font-size: 14px;
font-weight: 600;
color: #333;
}
.a4-kind-option-desc {
font-size: 11px;
line-height: 1.45;
color: #777;
}
/* 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: 10px 4px 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #999;
}
.load-dialog-item--other {
opacity: 0.6;
}
.load-dialog-item--other:hover {
opacity: 1;
}
.load-dialog-kind-badge {
margin-left: 8px;
padding: 1px 6px;
border-radius: 9px;
background: #e8e8e8;
color: #666;
font-size: 10px;
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: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.a4-page-setup {
background: #fff;
border-radius: 8px;
width: 340px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.a4-page-setup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e0e0e0;
}
.a4-page-setup-header h2 {
margin: 0;
font-size: 16px;
color: #333;
}
.a4-page-setup-close {
border: none;
background: none;
font-size: 22px;
line-height: 1;
color: #888;
cursor: pointer;
}
.a4-page-setup-close:hover {
color: #333;
}
.a4-page-setup-body {
padding: 8px 18px 18px;
overflow-y: auto;
}
.a4-page-setup-dims,
.a4-page-setup-hint {
font-size: 11px;
color: #888;
margin-top: 6px;
line-height: 1.4;
}
.a4-page-setup-dims {
color: #667eea;
}
@@ -0,0 +1,121 @@
import React from 'react';
import { MmInput } from './A4Inspector';
import { FONT_FAMILIES } from './fonts';
import { PAGE } from './units';
import '../ObjectInspector.css';
import './A4Inspector.css';
import './A4PageSetup.css';
/**
* Page setup panel: orientation, the four margins in mm, and the defaults new
* elements inherit.
*
* Margins are NOT cosmetic. A band's x/width are derived from the content area
* (A4Canvas `contentWidthOf`), so changing a margin has to reflow every existing
* band — otherwise a band keeps the width it was created with and silently
* overhangs the new margin. `A4ReportEditor.handlePageSetupChange` owns that
* reflow; this panel only reports the new setup.
*
* Margins use MmInput for the same reason every other mm field does: a
* `type="number"` input discards the intermediate "1." and the decimal point
* appears not to type. Font size is pt with a 0.5 step, matching the inspector.
*/
const MARGIN_FIELDS = [
{ key: 'top', label: 'Top' },
{ key: 'right', label: 'Right' },
{ key: 'bottom', label: 'Bottom' },
{ key: 'left', label: 'Left' }
];
const FONT_SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48];
function A4PageSetup({ pageSetup, onChange, onClose }) {
const setup = pageSetup || {};
const margins = setup.margins || {};
const orientation = setup.orientation === 'landscape' ? 'landscape' : 'portrait';
const size = PAGE.A4[orientation];
const handleMarginChange = (key, value) => {
onChange({ ...setup, margins: { ...margins, [key]: value } });
};
return (
<div className="a4-page-setup-overlay" onClick={onClose}>
<div className="a4-page-setup" onClick={e => e.stopPropagation()}>
<div className="a4-page-setup-header">
<h2>Page Setup</h2>
<button className="a4-page-setup-close" onClick={onClose} title="Close">×</button>
</div>
<div className="a4-page-setup-body a4-inspector">
<div className="inspector-section">
<div className="section-title">Page</div>
<div className="property-row">
<label>Orientation</label>
<select
value={orientation}
onChange={e => onChange({ ...setup, orientation: e.target.value })}
>
<option value="portrait">Portrait</option>
<option value="landscape">Landscape</option>
</select>
</div>
<div className="a4-page-setup-dims">
A4 {orientation} — {size.w} × {size.h} mm
</div>
</div>
<div className="inspector-section">
<div className="section-title">Margins (mm)</div>
{MARGIN_FIELDS.map(({ key, label }) => (
<MmInput
key={key}
label={label}
value={margins[key] ?? 0}
min={0}
onCommit={(v) => handleMarginChange(key, v)}
/>
))}
<div className="a4-page-setup-hint">
Bands span the content width, so changing a side margin resizes
every band in the report.
</div>
</div>
<div className="inspector-section">
<div className="section-title">Defaults for new elements</div>
<div className="property-row">
<label>Font</label>
<select
value={setup.defaultFont || 'Arial'}
onChange={e => onChange({ ...setup, defaultFont: e.target.value })}
>
{FONT_FAMILIES.map(f => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div className="property-row">
<label>Size (pt)</label>
<select
value={setup.defaultFontSize || 10}
onChange={e => onChange({ ...setup, defaultFontSize: parseFloat(e.target.value) })}
>
{FONT_SIZES.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="a4-page-setup-hint">
Applies to elements created from now on. Existing elements keep
the font they were given.
</div>
</div>
</div>
</div>
</div>
);
}
export default A4PageSetup;
@@ -0,0 +1,21 @@
/* A4 editor shell — layered on the shared ReportEditor.css. */
/* The inspector is position:fixed on the left, so the canvas keeps clear of it. */
.a4-editor-layout {
margin-left: 250px;
}
.toolbar-zoom {
padding: 8px 10px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
font-size: 14px;
color: #333;
cursor: pointer;
}
.toolbar-zoom:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
@@ -0,0 +1,636 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { DataProvider, useReportData } from '../DataContext';
import { BandProvider } from '../BandContext';
import { useNomenclatureData } from '../../../contexts/NomenclatureDataContext';
import { roundMm, getPageSize } from './units';
import A4Toolbar from './A4Toolbar';
import A4Canvas from './A4Canvas';
import A4Inspector from './A4Inspector';
import A4PageSetup from './A4PageSetup';
import A4KindChooser from './A4KindChooser';
import { buildSampleVehicleData } from './sampleData';
import { printA4Report } from './print/printReport';
import { buildCalibrationReport } from './calibrationReport';
import api from '../../../services/api';
import '../ReportEditor.css';
import './A4ReportEditor.css';
/**
* A4 free-positioning report designer.
*
* Deliberately a sibling of the matrix editor rather than a refactor of it:
* the matrix tree is the code path the one working report feature depends on.
* Where logic looks similar it is copied, not extracted (see the plan's
* "matrix editor is not modified" section). Inspector sections will therefore
* drift between the two editors — that is the accepted price.
*/
/** A band's x/width come from the page content area, never from the user, so a
* margin change must resize every existing band. Without this a band keeps the
* width it was created with and overhangs (or falls short of) the new margin,
* and — because layout() positions band children relative to the band — the
* whole printed page drifts. Mirrors A4Canvas's `contentWidthOf`. */
const reflowBands = (elements, pageSetup) => {
const size = getPageSize(pageSetup);
const m = pageSetup?.margins || {};
const x = roundMm(m.left || 0);
const width = roundMm(size.w - (m.left || 0) - (m.right || 0));
return elements.map(el =>
el.type === 'a4-band' ? { ...el, x, width } : el
);
};
const DEFAULT_PAGE_SETUP = {
page: 'A4',
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial',
defaultFontSize: 10
};
/** Arrow key → [property to change, direction]. */
const NUDGE_AXES = {
ArrowLeft: ['x', -1],
ArrowRight: ['x', 1],
ArrowUp: ['y', -1],
ArrowDown: ['y', 1]
};
const createEmptyReport = () => ({
name: 'Untitled A4 Report',
kind: 'a4',
pageSetup: { ...DEFAULT_PAGE_SETUP, margins: { ...DEFAULT_PAGE_SETUP.margins } },
apiEndpoint: '',
elements: []
});
/** API row (snake_case, page_setup possibly `{}`) → the editor's in-memory shape.
* Shared by the load dialog and the router-state auto-load so the two cannot
* disagree about defaults. */
const fromApiReport = (data) => {
const loadedSetup = data.page_setup && Object.keys(data.page_setup).length > 0
? data.page_setup
: DEFAULT_PAGE_SETUP;
const pageSetup = {
...DEFAULT_PAGE_SETUP,
...loadedSetup,
margins: { ...DEFAULT_PAGE_SETUP.margins, ...(loadedSetup.margins || {}) }
};
return {
name: data.name,
kind: 'a4',
pageSetup,
apiEndpoint: data.api_endpoint || '',
// Reflow on the way in as well as on a margin change: a report saved before
// bands were pinned can hold a band at an arbitrary x, and nothing else
// would ever straighten it — the drag lock keeps x, it does not correct it.
elements: reflowBands(data.elements || [], pageSetup)
};
};
function A4ReportEditorContent() {
const [report, setReport] = useState(createEmptyReport);
const [selectedElementIds, setSelectedElementIds] = useState([]);
const [toolMode, setToolMode] = useState('select');
const [zoom, setZoom] = useState(1);
const [previewMode, setPreviewMode] = useState(false);
const { reportData, setData } = useReportData();
const [reportId, setReportId] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [showLoadDialog, setShowLoadDialog] = useState(false);
const [savedReports, setSavedReports] = useState([]);
const [otherKindReports, setOtherKindReports] = useState([]);
const [loadingReports, setLoadingReports] = useState(false);
const [showPageSetup, setShowPageSetup] = useState(false);
const [showKindChooser, setShowKindChooser] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { definitions, entries, isLoaded } = useNomenclatureData();
/**
* Auto-load the report named in router state — how "Design Notice" in
* Main.jsx lands on the right document with the operator's live vehicle on it.
*
* Ported from the matrix editor (ReportEditor.jsx:41-67) with one change: the
* lookup is a server-side filter (`?kind=a4&name=...`) rather than
* fetch-all-then-find, now that django-filter is actually wired up. Filtering
* on kind matters — a matrix report of the same name must not be loaded into
* the mm editor, where its character coordinates would read as millimetres.
*
* Runs once on mount, deliberately: re-running on a location change would
* discard unsaved edits.
*/
useEffect(() => {
const { reportName, vehicleData } = location.state || {};
if (!reportName) return;
api.get(`/api/reports/?kind=a4&name=${encodeURIComponent(reportName)}`)
.then(res => {
const found = (res.data.results || res.data || [])[0];
if (found) {
setReport(fromApiReport(found));
setReportId(found.id);
} else {
// Nothing saved under that name yet — pre-fill it so Save creates it.
setReport(prev => ({ ...prev, name: reportName }));
}
if (vehicleData) setData(vehicleData);
})
.catch(err => {
console.error('Failed to auto-load report:', err);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* No vehicle data passed (someone opened the editor directly) → synthesise
* sample data once nomenclatures have loaded, so field bindings can be picked
* and previewed against something. Ported from ReportEditor.jsx:71-116.
*/
useEffect(() => {
if (!isLoaded || location.state?.vehicleData) return;
setData(buildSampleVehicleData(definitions, entries));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoaded]);
/**
* Add an element, adopting it into a band if it was dropped inside one.
*
* Ported from the matrix editor (ReportEditor.jsx:118-162) and kept in mm.
* A child's stored `y` becomes RELATIVE to its band, which is what both the
* canvas (children render inside the band element) and layout() (`y + child.y`
* per instance) assume. A band is never itself adopted — nesting is set
* explicitly via parentBandId, not by position.
*/
const handleAddElement = (element) => {
setReport(prev => {
const containingBand = element.type === 'a4-band'
? null
: prev.elements.find(el =>
el.type === 'a4-band' &&
element.y >= el.y &&
element.y < el.y + el.height
);
const adopted = containingBand
? { ...element, y: roundMm(element.y - containingBand.y) }
: element;
let elements = [...prev.elements, adopted];
if (containingBand) {
elements = elements.map(el =>
el.id === containingBand.id
? { ...el, children: [...(el.children || []), adopted.id] }
: el
);
}
return { ...prev, elements };
});
setSelectedElementIds([element.id]);
setToolMode('select');
};
/**
* Update an element. Growing or shrinking a band pushes everything below it,
* so bands stay in flow order and cannot silently overlap — the matrix
* editor's behaviour (ReportEditor.jsx:164-199), in mm.
*/
const handleElementUpdate = useCallback((elementId, updates) => {
setReport(prev => {
const element = prev.elements.find(el => el.id === elementId);
if (element?.type === 'a4-band' &&
updates.height !== undefined &&
updates.height !== element.height) {
const delta = updates.height - element.height;
const bandEnd = element.y + element.height;
const childIds = new Set(element.children || []);
return {
...prev,
elements: prev.elements.map(el => {
if (el.id === elementId) return { ...el, ...updates };
// Children move WITH the band (their y is relative), so only
// elements that are both outside it and below it shift.
if (!childIds.has(el.id) && el.y >= bandEnd) {
return { ...el, y: roundMm(el.y + delta) };
}
return el;
})
};
}
return {
...prev,
elements: prev.elements.map(el =>
el.id === elementId ? { ...el, ...updates } : el
)
};
});
}, []);
/** Deleting a band deletes its children too — an orphan keeps a relative y
* with nothing to be relative to, and would jump to the page origin. */
const handleElementDelete = useCallback((elementId) => {
setReport(prev => {
const element = prev.elements.find(el => el.id === elementId);
const doomed = new Set([elementId]);
if (element?.type === 'a4-band') {
(element.children || []).forEach(id => doomed.add(id));
}
return {
...prev,
elements: prev.elements
.filter(el => !doomed.has(el.id))
// Drop the id from any band that owned it.
.map(el => el.type === 'a4-band' && el.children?.some(id => doomed.has(id))
? { ...el, children: el.children.filter(id => !doomed.has(id)) }
: el
)
};
});
setSelectedElementIds(prev => prev.filter(id => id !== elementId));
}, []);
const handleSelectElement = useCallback((elementId, addToSelection = false) => {
setSelectedElementIds(prev => {
if (addToSelection) {
return prev.includes(elementId)
? prev.filter(id => id !== elementId)
: [...prev, elementId];
}
// Clicking an element that is part of a multi-selection keeps that
// selection, so the whole group can be dragged together.
if (prev.length > 1 && prev.includes(elementId)) return prev;
return [elementId];
});
}, []);
const handleSelectMultiple = useCallback((ids) => setSelectedElementIds(ids), []);
const handleDeselectAll = useCallback(() => setSelectedElementIds([]), []);
const handleToolChange = (newTool) => {
setToolMode(newTool);
if (newTool !== 'select') setSelectedElementIds([]);
};
// Save — the backend also has page_width/page_height, but they are character
// columns/rows for matrix reports and carry model defaults (80/66), so an A4
// report simply omits them rather than overloading them with millimetres.
const handleSave = async () => {
if (!report.name.trim()) {
alert('Please enter a report name');
return;
}
setIsSaving(true);
try {
const payload = {
name: report.name,
kind: 'a4',
page_setup: report.pageSetup,
api_endpoint: report.apiEndpoint,
elements: report.elements
};
if (reportId) {
await api.put(`/api/reports/${reportId}/`, payload);
} else {
const res = await api.post('/api/reports/', payload);
setReportId(res.data.id);
}
alert('Report saved successfully');
} catch (err) {
console.error('Failed to save report:', err);
alert('Failed to save report: ' + (err.response?.data?.detail || err.message));
} finally {
setIsSaving(false);
}
};
// Prints the in-memory report, so an unsaved design can be checked on paper
// immediately. Phase 5 will pass real resolved data in place of {}.
const handlePrint = useCallback(() => {
printA4Report(report, {});
}, [report]);
// Loads the ruler-test fixture. Deliberately not persisted to the backend —
// it is a measurement tool, not a report, and leaving rows behind in the
// customer's report list would be noise.
const handleLoadCalibration = useCallback(() => {
if (!window.confirm('Replace the current report with the calibration sheet?')) return;
setReport(buildCalibrationReport());
setReportId(null);
setSelectedElementIds([]);
}, []);
/**
* List A4 reports, plus matrix ones in a dimmed group.
*
* The cross-kind group is not a nicety: an operator who saves an A4 report and
* then opens the matrix editor finds it missing and files a support call. The
* fetches are independent, so a failure of the secondary one must not blank
* the primary list — hence the separate catch rather than a Promise.all.
*/
const handleLoadClick = async () => {
setLoadingReports(true);
setShowLoadDialog(true);
try {
const res = await api.get('/api/reports/?kind=a4');
setSavedReports(res.data.results || res.data || []);
} catch (err) {
console.error('Failed to load reports list:', err);
alert('Failed to load reports list');
} finally {
setLoadingReports(false);
}
try {
const res = await api.get('/api/reports/?kind=matrix');
setOtherKindReports(res.data.results || res.data || []);
} catch (err) {
// Non-fatal — the A4 list above is what the dialog is for.
console.error('Failed to load other-kind reports:', err);
setOtherKindReports([]);
}
};
/** Open a matrix report where it can actually be edited. Router state mirrors
* what Main.jsx sends, so the matrix editor's own auto-load picks it up. */
const handleOpenOtherKind = (r) => {
navigate('/report-editor', {
state: { reportName: r.name, vehicleData: location.state?.vehicleData }
});
};
const handleChooseKind = (kind) => {
setShowKindChooser(false);
if (kind === 'a4') {
startNewReport();
return;
}
setShowLoadDialog(false);
navigate('/report-editor');
};
/** Page setup changes reflow bands in the same commit as the setup itself, so
* the two can never be briefly inconsistent. */
const handlePageSetupChange = useCallback((pageSetup) => {
setReport(prev => ({
...prev,
pageSetup,
elements: reflowBands(prev.elements, pageSetup)
}));
}, []);
const handleLoadReport = async (id) => {
try {
const res = await api.get(`/api/reports/${id}/`);
const data = res.data;
setReport(fromApiReport(data));
setReportId(data.id);
setSelectedElementIds([]);
setShowLoadDialog(false);
} catch (err) {
console.error('Failed to load report:', err);
alert('Failed to load report');
}
};
const handleDeleteReport = async (id) => {
if (!window.confirm('Are you sure you want to delete this report?')) return;
try {
await api.delete(`/api/reports/${id}/`);
setSavedReports(prev => prev.filter(r => r.id !== id));
if (reportId === id) setReportId(null);
} catch (err) {
console.error('Failed to delete report:', err);
alert('Failed to delete report');
}
};
const startNewReport = () => {
setReport(createEmptyReport());
setReportId(null);
setSelectedElementIds([]);
setShowLoadDialog(false);
};
/** "+ New Report" asks which kind first. The two are not convertible, so the
* choice has to be made before any layout work, not discovered after it. */
const handleNewReport = () => {
if (report.elements.length > 0) {
if (!window.confirm('Discard current report and create a new one?')) return;
}
setShowKindChooser(true);
};
// Keyboard shortcuts. Typing in an input must never delete elements or hijack
// Ctrl+A, so text entry short-circuits everything except Escape.
useEffect(() => {
const handleKeyDown = (e) => {
const tag = e.target.tagName;
const isTextEntry = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
if (e.key === 'Escape') {
setSelectedElementIds([]);
setToolMode('select');
return;
}
if (isTextEntry) return;
if (e.key === 'Delete' && selectedElementIds.length > 0) {
setReport(prev => ({
...prev,
elements: prev.elements.filter(el => !selectedElementIds.includes(el.id))
}));
setSelectedElementIds([]);
} else if (e.key.toLowerCase() === 'a' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
setSelectedElementIds(report.elements.map(el => el.id));
} else if (NUDGE_AXES[e.key] && selectedElementIds.length > 0) {
// Arrow nudge: 1mm, or 0.1mm with Shift — the storage precision, so
// Shift is as fine as the format can express. preventDefault stops the
// canvas scrolling out from under the element being nudged.
e.preventDefault();
const [axis, sign] = NUDGE_AXES[e.key];
const step = sign * (e.shiftKey ? 0.1 : 1);
setReport(prev => ({
...prev,
elements: prev.elements.map(el =>
selectedElementIds.includes(el.id)
? { ...el, [axis]: roundMm(el[axis] + step) }
: el
)
}));
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedElementIds, report.elements]);
const selectedElement = selectedElementIds.length === 1
? report.elements.find(el => el.id === selectedElementIds[0])
: null;
return (
<div className="report-editor">
<A4Toolbar
toolMode={toolMode}
onToolChange={handleToolChange}
zoom={zoom}
onZoomChange={setZoom}
reportName={report.name}
onReportNameChange={(name) => setReport(prev => ({ ...prev, name }))}
onSave={handleSave}
onLoad={handleLoadClick}
onPrint={handlePrint}
onPageSetup={() => setShowPageSetup(true)}
onLoadCalibration={handleLoadCalibration}
previewMode={previewMode}
onTogglePreview={() => setPreviewMode(p => !p)}
isSaving={isSaving}
/>
<div className="editor-layout a4-editor-layout">
<A4Canvas
elements={report.elements}
selectedElementIds={selectedElementIds}
onElementSelect={handleSelectElement}
onSelectMultiple={handleSelectMultiple}
onDeselectAll={handleDeselectAll}
onElementUpdate={handleElementUpdate}
onElementDelete={handleElementDelete}
onAddElement={handleAddElement}
toolMode={toolMode}
pageSetup={report.pageSetup}
zoom={zoom}
previewMode={previewMode}
reportData={reportData}
/>
</div>
{/* The inspector edits the design, so it is meaningless over a preview. */}
{selectedElement && !previewMode && (
<A4Inspector
element={selectedElement}
allElements={report.elements}
reportData={reportData}
onUpdate={(updates) => handleElementUpdate(selectedElement.id, updates)}
/>
)}
{showLoadDialog && (
<div className="load-dialog-overlay" onClick={() => setShowLoadDialog(false)}>
<div className="load-dialog" onClick={e => e.stopPropagation()}>
<div className="load-dialog-header">
<h2>Load A4 Report</h2>
<button className="load-dialog-close" onClick={() => setShowLoadDialog(false)}>×</button>
</div>
<div className="load-dialog-actions">
<button className="load-dialog-new-btn" onClick={handleNewReport}>
+ New Report
</button>
</div>
<div className="load-dialog-content">
{loadingReports ? (
<div className="load-dialog-loading">Loading...</div>
) : (
<>
{savedReports.length === 0 ? (
<div className="load-dialog-empty">No saved A4 reports</div>
) : (
<ul className="load-dialog-list">
{savedReports.map(r => (
<li
key={r.id}
className={`load-dialog-item ${reportId === r.id ? 'load-dialog-item--current' : ''}`}
>
<div className="load-dialog-item-info" onClick={() => handleLoadReport(r.id)}>
<span className="load-dialog-item-name">{r.name}</span>
<span className="load-dialog-item-date">
{new Date(r.updated_at).toLocaleDateString()}
</span>
</div>
<button
className="load-dialog-item-delete"
onClick={() => handleDeleteReport(r.id)}
title="Delete report"
>
🗑
</button>
</li>
))}
</ul>
)}
{/* Matrix reports, dimmed. Listed so a report saved in the
other editor is visibly elsewhere rather than apparently
lost; clicking one opens the editor that can edit it. */}
{otherKindReports.length > 0 && (
<>
<div className="load-dialog-group-title">Other reports</div>
<ul className="load-dialog-list">
{otherKindReports.map(r => (
<li
key={r.id}
className="load-dialog-item load-dialog-item--other"
title="Opens in the character matrix editor"
>
<div className="load-dialog-item-info" onClick={() => handleOpenOtherKind(r)}>
<span className="load-dialog-item-name">
{r.name}
<span className="load-dialog-kind-badge">matrix</span>
</span>
<span className="load-dialog-item-date">
{new Date(r.updated_at).toLocaleDateString()}
</span>
</div>
</li>
))}
</ul>
</>
)}
</>
)}
</div>
</div>
</div>
)}
{showPageSetup && (
<A4PageSetup
pageSetup={report.pageSetup}
onChange={handlePageSetupChange}
onClose={() => setShowPageSetup(false)}
/>
)}
{showKindChooser && (
<A4KindChooser
onChoose={handleChooseKind}
onCancel={() => setShowKindChooser(false)}
/>
)}
</div>
);
}
function A4ReportEditor() {
return (
<DataProvider>
<BandProvider>
<A4ReportEditorContent />
</BandProvider>
</DataProvider>
);
}
export default A4ReportEditor;
@@ -0,0 +1,138 @@
import React from 'react';
import '../Toolbar.css';
const ZOOM_LEVELS = [0.5, 0.75, 1, 1.5];
/** Creation tools, in the order they appear. Adding a type is one row here
* plus one factory in A4Canvas and one case in its render switch. */
const TOOLS = [
{ mode: 'addText', label: 'T Text', title: 'Add Text Field' },
{ mode: 'addDBText', label: '{} Data', title: 'Add Data Field (bound to a report field)' },
{ mode: 'addExpr', label: 'Σ Expr', title: 'Add Expression Field (computed)' },
{ mode: 'addRect', label: '▭ Rect', title: 'Add Rectangle' },
{ mode: 'addHLine', label: '─ H-Line', title: 'Add Horizontal Line' },
{ mode: 'addVLine', label: '│ V-Line', title: 'Add Vertical Line' },
{ mode: 'addBand', label: '▤ Band', title: 'Add Band (repeats once per data row)' }
];
/** Toolbar for the A4 designer. */
function A4Toolbar({
toolMode,
onToolChange,
zoom,
onZoomChange,
reportName,
onReportNameChange,
onSave,
onLoad,
onPrint,
onPageSetup,
onLoadCalibration,
previewMode = false,
onTogglePreview,
isSaving = false
}) {
return (
<div className="toolbar">
<div className="toolbar-group">
<button
className={`toolbar-button ${toolMode === 'select' ? 'active' : ''}`}
onClick={() => onToolChange('select')}
title="Select Tool"
>
↖️ Select
</button>
{TOOLS.map(({ mode, label, title }) => (
<button
key={mode}
className={`toolbar-button ${toolMode === mode ? 'active' : ''}`}
onClick={() => onToolChange(mode)}
title={title}
>
{label}
</button>
))}
</div>
<div className="toolbar-separator"></div>
<div className="toolbar-group">
<label className="toolbar-label">Zoom:</label>
<select
className="toolbar-zoom"
value={zoom}
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
title="Zoom level"
>
{ZOOM_LEVELS.map(z => (
<option key={z} value={z}>{Math.round(z * 100)}%</option>
))}
</select>
</div>
<div className="toolbar-separator"></div>
<div className="toolbar-group toolbar-group--file">
<input
type="text"
className="toolbar-report-name"
value={reportName || ''}
onChange={(e) => onReportNameChange(e.target.value)}
placeholder="Report name"
title="Report name"
/>
<button
className="toolbar-button"
onClick={onSave}
disabled={isSaving}
title="Save Report"
>
{isSaving ? '...' : '💾 Save'}
</button>
<button
className="toolbar-button"
onClick={onLoad}
title="Load Report"
>
📂 Load
</button>
{/* Preview renders layout()'s pages — the same output the print path
consumes, so its page count is the printed page count. */}
<button
className={`toolbar-button ${previewMode ? 'active' : ''}`}
onClick={onTogglePreview}
title="Preview with data, paginated as it will print"
>
👁 Preview
</button>
{/* Prints the CURRENT in-memory report — no save required first, which
is what makes design iteration quick. */}
<button
className="toolbar-button"
onClick={onPrint}
title="Print (choose 'Save as PDF' as the destination for a file)"
>
🖨 Print
</button>
</div>
<div className="toolbar-separator"></div>
<div className="toolbar-group">
{/* Orientation and margins live here rather than on the canvas: they
change the page every element sits on, so they are a document
property, not a selection property. */}
<button
className="toolbar-button"
onClick={onPageSetup}
title="Page setup — orientation, margins, default font"
>
⚙ Page Setup
</button>
<button
className="toolbar-button"
onClick={onLoadCalibration}
title="Load the calibration report for the ruler test (replaces the current report)"
>
📐 Calibration
</button>
</div>
</div>
);
}
export default A4Toolbar;
@@ -0,0 +1,433 @@
/**
* Phase 6 — bands and multi-page pagination.
*
* The load-bearing assertion in this file is that layout() is the SINGLE source
* of pagination: preview and print both consume its `pages` array, so their page
* counts cannot drift. Everything else here protects the arithmetic that feeds
* it — band instancing in millimetres, atomic instances, repeating headers and
* footers, and static elements staying on page 1.
*
* The mm arithmetic is the part most likely to regress: the matrix engine this
* was ported from uses INCLUSIVE cell offsets (`- 1` / `+ 1`), which are wrong
* for continuous millimetres. A gap test below pins that.
*/
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
// React 18's concurrent act() needs this flag, or every unmount logs a warning.
global.IS_REACT_ACT_ENVIRONMENT = true;
import A4Canvas from '../A4Canvas';
import PrintDocument from '../print/PrintDocument';
import { layout } from '../layout';
import { calculateBandInstances } from '../a4BandRenderer';
const PAGE_SETUP = {
page: 'A4',
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial',
defaultFontSize: 10
};
const CONTENT_BOTTOM = 297 - 15;
const band = (over = {}) => ({
id: 'band-1',
type: 'a4-band',
x: 20,
y: 15,
width: 175,
height: 8,
bandType: 'detail',
dataSource: 'rows',
caption: '',
children: [],
parentBandId: null,
...over
});
const text = (over = {}) => ({
id: 'text-1',
type: 'a4-text',
x: 20,
y: 1,
width: 60,
height: 6,
content: 'X',
fontFamily: 'Arial',
fontSize: 10,
bold: false,
italic: false,
align: 'left',
color: '#000000',
...over
});
const rows = (n) => Array.from({ length: n }, (_, i) => ({ name: `Row ${i + 1}` }));
describe('band instancing (millimetres)', () => {
test('one instance per data row, stacked by height', () => {
const b = band({ height: 8, children: ['text-1'] });
const instances = calculateBandInstances([b], { rows: rows(3) }, [b, text()]);
expect(instances).toHaveLength(3);
expect(instances[0].y).toBeCloseTo(15, 5);
expect(instances[1].y).toBeCloseTo(23, 5);
expect(instances[2].y).toBeCloseTo(31, 5);
});
test('a band with no dataSource renders exactly once', () => {
const b = band({ dataSource: '', bandType: 'header' });
const instances = calculateBandInstances([b], {}, [b]);
expect(instances).toHaveLength(1);
expect(instances[0].data).toBeNull();
});
test('the design gap between bands is preserved exactly, with no off-by-one', () => {
// The matrix engine subtracts 1 here because rows are inclusive cells.
// In mm a 5mm gap must stay 5mm — this is the ported arithmetic's real risk.
const first = band({ id: 'b1', y: 15, height: 10, dataSource: '' });
const second = band({ id: 'b2', y: 30, height: 10, dataSource: '' });
const instances = calculateBandInstances([first, second], {}, [first, second]);
const gap = instances[1].y - (instances[0].y + instances[0].height);
expect(gap).toBeCloseTo(5, 5);
});
});
describe('pagination', () => {
const multiPage = () => {
const header = band({
id: 'hdr', y: 15, height: 10, bandType: 'header',
dataSource: '', children: ['hdr-text']
});
const detail = band({
id: 'det', y: 30, height: 8, bandType: 'detail',
dataSource: 'rows', children: ['cell']
});
const elements = [
header,
text({ id: 'hdr-text', y: 2, content: 'HEADER' }),
detail,
text({ id: 'cell', type: 'a4-dbtext', y: 1, objectKey: '', fieldPath: 'name', dataType: 'general' })
];
return layout(elements, { rows: rows(60) }, PAGE_SETUP);
};
test('60 rows of 8mm overflow onto a second page', () => {
const { pages } = multiPage();
expect(pages.length).toBeGreaterThan(1);
});
test('a header band repeats at the top of every page', () => {
const { pages } = multiPage();
pages.forEach(page => {
expect(page.some(p => p.el.id === 'hdr-text')).toBe(true);
});
});
test('every data row is emitted exactly once across all pages', () => {
const { pages } = multiPage();
const values = pages.flat().filter(p => p.el.id === 'cell').map(p => p.text);
expect(values).toHaveLength(60);
expect(new Set(values).size).toBe(60);
});
test('no instance is split across a page boundary', () => {
const { pages } = multiPage();
pages.flat().forEach(({ el, y }) => {
const h = el.type === 'a4-hline' ? el.thickness : (el.height || 0);
expect(y + h).toBeLessThanOrEqual(CONTENT_BOTTOM + 0.01);
});
});
test('a footer band repeats at the bottom of every page', () => {
const footer = band({
id: 'ftr', y: 270, height: 8, bandType: 'footer',
dataSource: '', children: ['ftr-text']
});
const detail = band({ id: 'det', y: 30, height: 8, dataSource: 'rows', children: [] });
const { pages } = layout(
[footer, text({ id: 'ftr-text', y: 1, content: 'FOOTER' }), detail],
{ rows: rows(60) },
PAGE_SETUP
);
expect(pages.length).toBeGreaterThan(1);
pages.forEach(page => {
expect(page.some(p => p.el.id === 'ftr-text')).toBe(true);
});
});
test('static elements print on page 1 only', () => {
const detail = band({ id: 'det', y: 40, height: 8, dataSource: 'rows', children: [] });
const { pages } = layout(
[text({ id: 'static', y: 20, content: 'ONCE' }), detail],
{ rows: rows(60) },
PAGE_SETUP
);
expect(pages[0].filter(p => p.el.id === 'static')).toHaveLength(1);
expect(pages.slice(1).flat().filter(p => p.el.id === 'static')).toHaveLength(0);
});
test('an instance taller than the page emits once and does not loop forever', () => {
const huge = band({ id: 'huge', y: 15, height: 400, dataSource: 'rows', children: ['cell'] });
const { pages } = layout(
[huge, text({ id: 'cell', y: 1 })],
{ rows: rows(2) },
PAGE_SETUP
);
const emitted = pages.flat().filter(p => p.el.id === 'cell');
expect(emitted).toHaveLength(2);
});
test('band children resolve against their own row, not the top-level data', () => {
const detail = band({ id: 'det', y: 15, height: 8, dataSource: 'rows', children: ['cell'] });
const cell = text({
id: 'cell', type: 'a4-dbtext', y: 1,
objectKey: '', fieldPath: 'name', dataType: 'general'
});
const { pages } = layout([detail, cell], { rows: rows(3) }, PAGE_SETUP);
const values = pages.flat().filter(p => p.el.id === 'cell').map(p => p.text);
expect(values).toEqual(['Row 1', 'Row 2', 'Row 3']);
});
test('Cyrillic survives band resolution', () => {
const detail = band({ id: 'det', y: 15, height: 8, dataSource: 'rows', children: ['cell'] });
const cell = text({
id: 'cell', type: 'a4-dbtext', y: 1,
objectKey: '', fieldPath: 'plate', dataType: 'general'
});
const { pages } = layout([detail, cell], { rows: [{ plate: 'АА1234ВВ' }] }, PAGE_SETUP);
expect(pages.flat().find(p => p.el.id === 'cell').text).toBe('АА1234ВВ');
});
});
describe('preview and print consume the same pages', () => {
const build = () => {
const header = band({
id: 'hdr', y: 15, height: 10, bandType: 'header', dataSource: '', children: ['hdr-text']
});
const detail = band({
id: 'det', y: 30, height: 8, dataSource: 'rows', children: ['cell']
});
return {
elements: [
header,
text({ id: 'hdr-text', y: 2, content: 'HEADER' }),
detail,
text({ id: 'cell', type: 'a4-dbtext', y: 1, objectKey: '', fieldPath: 'name' })
],
data: { rows: rows(60) }
};
};
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
test('preview page count === layout page count === print page count', () => {
const { elements, data } = build();
const { pages } = layout(elements, data, PAGE_SETUP);
// Preview
const previewRoot = createRoot(container);
act(() => {
previewRoot.render(
<A4Canvas
elements={elements}
selectedElementIds={[]}
onElementSelect={() => {}}
onSelectMultiple={() => {}}
onDeselectAll={() => {}}
onElementUpdate={() => {}}
onElementDelete={() => {}}
onAddElement={() => {}}
toolMode="select"
pageSetup={PAGE_SETUP}
zoom={1}
previewMode
reportData={data}
/>
);
});
const previewPages = container.querySelectorAll('.a4-preview-page-wrapper').length;
act(() => previewRoot.unmount());
// Print
const printRoot = createRoot(container);
act(() => {
printRoot.render(<PrintDocument pages={pages} pageSetup={PAGE_SETUP} />);
});
const printPages = container.querySelectorAll('.a4-print-page').length;
act(() => printRoot.unmount());
expect(previewPages).toBe(pages.length);
expect(printPages).toBe(pages.length);
expect(previewPages).toBe(printPages);
});
});
describe('bands do not print themselves', () => {
test('only children reach the page, never the band box', () => {
const detail = band({ id: 'det', y: 15, height: 8, dataSource: 'rows', children: ['cell'] });
const { pages } = layout([detail, text({ id: 'cell', y: 1 })], { rows: rows(2) }, PAGE_SETUP);
expect(pages.flat().some(p => p.el.type === 'a4-band')).toBe(false);
expect(pages.flat().filter(p => p.el.id === 'cell')).toHaveLength(2);
});
});
/**
* Preview/print must agree with the designer about WHERE a band sits.
*
* Both halves of the same complaint: a band previewed at the top-left of the
* paper rather than where it was drawn inside the margins.
*/
describe('band placement matches the design', () => {
test('a band previews at the Y it was placed at, not the top margin', () => {
// Placed well down the page, with no data so it renders exactly once.
const b = band({ y: 120, height: 10, dataSource: '', children: ['text-1'] });
const child = text({ id: 'text-1', x: 5, y: 2 });
const { pages } = layout([b, child], {}, PAGE_SETUP);
const [placed] = pages[0];
// Design Y (120) + the child's band-relative y (2) — NOT contentTop (15).
expect(placed.y).toBeCloseTo(122, 6);
});
test("a child's x is measured from the band's left edge, not the paper edge", () => {
const b = band({ y: 40, dataSource: '', children: ['text-1'] });
const child = text({ id: 'text-1', x: 5, y: 1 });
const { pages } = layout([b, child], {}, PAGE_SETUP);
const [placed] = pages[0];
// Band x is the 20mm left margin, child sits 5mm into it => 25mm.
expect(placed.x).toBeCloseTo(25, 6);
// The bug put it at 5mm — inside the margin, against the paper edge.
expect(placed.x).not.toBeCloseTo(5, 6);
});
test('designed gaps still hold once a band grows with its rows', () => {
const first = band({ id: 'b1', y: 50, height: 10, dataSource: 'rows', children: [] });
const second = band({ id: 'b2', y: 80, height: 10, dataSource: '', children: ['text-1'] });
const child = text({ id: 'text-1', x: 0, y: 0 });
// Design: first band 50..60, second at 80 => a drawn gap of 20mm.
// 3 rows grow the first band to 50..80, and the 20mm gap is kept AFTER it.
const { pages } = layout([first, second, child], { rows: rows(3) }, PAGE_SETUP);
const [placed] = pages[0];
expect(placed.y).toBeCloseTo(100, 6);
});
test('a band pushed past its design Y by growth is not pulled back up', () => {
const first = band({ id: 'b1', y: 50, height: 10, dataSource: 'rows' });
const second = band({ id: 'b2', y: 70, height: 10, dataSource: '', children: ['text-1'] });
const child = text({ id: 'text-1', x: 0, y: 0 });
// 5 rows => first band runs 50..100, overrunning the second's design y (70).
const { pages } = layout([first, second, child], { rows: rows(5) }, PAGE_SETUP);
const [placed] = pages[0];
// Flow wins: the second band sits after the first, never overlapping it.
expect(placed.y).toBeGreaterThanOrEqual(100);
});
});
/**
* The banded-report model: everything prints where it was drawn, and the only
* thing that moves anything is a band GROWING as it repeats per data row.
*/
describe('growth pushes what is below it', () => {
const header = (over = {}) => band({
id: 'hdr', y: 75, height: 12, bandType: 'header', dataSource: '',
children: ['h-text'], ...over
});
const detail = (over = {}) => band({
id: 'det', y: 90, height: 30, bandType: 'detail', dataSource: 'rows',
children: ['d-text'], ...over
});
const above = text({ id: 'above', x: 20, y: 60, content: 'title' });
const below = text({ id: 'below', x: 20, y: 130, content: 'total' });
const hText = text({ id: 'h-text', x: 5, y: 2, content: 'Col' });
const dText = text({ id: 'd-text', x: 8, y: 2, content: 'Row' });
const at = (pages, id, nth = 0) =>
pages.flat().filter(p => p.el.id === id)[nth];
test('with one row, everything keeps its design position', () => {
const els = [above, header(), hText, detail({ dataSource: '' }), dText, below];
const { pages } = layout(els, {}, PAGE_SETUP);
expect(at(pages, 'above').y).toBeCloseTo(60, 6); // static, untouched
expect(at(pages, 'h-text').y).toBeCloseTo(77, 6); // header at ITS y, not contentTop
expect(at(pages, 'd-text').y).toBeCloseTo(92, 6);
expect(at(pages, 'below').y).toBeCloseTo(130, 6);
});
test('a static below a grown band keeps its drawn gap', () => {
const els = [above, header(), hText, detail(), dText, below];
const { pages } = layout(els, { rows: rows(4) }, PAGE_SETUP);
// 4 rows x 30mm from y=90 => the band now ends at 210.
// `below` was drawn 10mm under the band's design bottom (120), so 210 + 10.
expect(at(pages, 'below').y).toBeCloseTo(220, 6);
// A static ABOVE every band does not move.
expect(at(pages, 'above').y).toBeCloseTo(60, 6);
});
test('the gap is measured from the LAST row, not the first', () => {
// Regression: every instance of one band shares a design position, so
// keying the displacement on the instance's own laid-out y measured the gap
// from row 1 and let the static sit on top of rows 2..n.
const els = [detail(), dText, below];
const { pages } = layout(els, { rows: rows(4) }, PAGE_SETUP);
const lastRow = at(pages, 'd-text', 3);
expect(at(pages, 'below').y).toBeGreaterThan(lastRow.y);
});
test('a header prints where drawn, then repeats on a continuation page', () => {
const els = [header({ y: 40, height: 10 }), hText, detail({ y: 55, height: 20 }), dText];
const { pages } = layout(els, { rows: rows(12) }, PAGE_SETUP);
expect(pages.length).toBe(2);
// Page 1: at its DESIGN y, not hoisted to the top margin.
expect(at(pages, 'h-text').y).toBeCloseTo(42, 6);
// Page 2: repeated at the top, because the rows it captions continue there.
const onPage2 = pages[1].filter(p => p.el.id === 'h-text');
expect(onPage2).toHaveLength(1);
expect(onPage2[0].y).toBeCloseTo(PAGE_SETUP.margins.top + 2, 6);
});
test('a header does not repeat when nothing overflows', () => {
const els = [header({ y: 40, height: 10 }), hText, detail({ y: 55, height: 20 }), dText];
const { pages } = layout(els, { rows: rows(2) }, PAGE_SETUP);
expect(pages.length).toBe(1);
expect(pages[0].filter(p => p.el.id === 'h-text')).toHaveLength(1);
});
test('a static pushed past the bottom margin follows onto the next page', () => {
const els = [detail({ y: 30, height: 20 }), dText, text({ id: 'below', x: 20, y: 60 })];
const { pages } = layout(els, { rows: rows(12) }, PAGE_SETUP);
const placed = at(pages, 'below');
expect(placed).toBeTruthy(); // not silently dropped
expect(placed.y).toBeLessThanOrEqual(CONTENT_BOTTOM);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,588 @@
/**
* Phase 7 — UI entry points.
*
* The thing worth testing here is DISPATCH, not rendering: given a saved report
* of a particular kind, does the operator end up in the right place? Getting
* that wrong is silent and expensive — a matrix report opened in the mm editor
* reads its character coordinates as millimetres and looks like a corrupt
* layout rather than a routing bug.
*
* Main.jsx itself is not mounted: it needs the auth, serial, nomenclature,
* scale-protocol and licence providers, so mounting it would test the provider
* stack rather than the dispatch. The dispatch is reproduced here against the
* same mocked api module Main.jsx uses, with the branch conditions copied from
* it verbatim — see `noticeDispatch` below.
*/
// A DELIBERATE behavioural stub, not a module-resolution workaround: these
// tests drive the report auto-load by controlling what the API returns (see the
// `api.get.mockResolvedValue` calls below), so the axios instance that
// services/api.js builds at import time has to be a mock. `create` and
// `interceptors` are here because api.js configures an instance on import.
vi.mock('axios', () => {
const instance = {
get: vi.fn(() => Promise.resolve({ data: [] })),
post: vi.fn(() => Promise.resolve({ data: {} })),
put: vi.fn(() => Promise.resolve({ data: {} })),
delete: vi.fn(() => Promise.resolve({ data: {} })),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() }
}
};
return {
__esModule: true,
default: { ...instance, create: vi.fn(() => instance) }
};
});
vi.mock('../../../../contexts/NomenclatureDataContext', () => ({
useNomenclatureData: () => ({
definitions: global.__NOM_DEFS__ || {},
entries: global.__NOM_ENTRIES__ || {},
isLoaded: global.__NOM_LOADED__ !== false
})
}));
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import api from '../../../../services/api';
import A4ReportEditor from '../A4ReportEditor';
import { buildSampleVehicleData } from '../sampleData';
import { getPageSize } from '../units';
global.IS_REACT_ACT_ENVIRONMENT = true;
let container;
let root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
vi.clearAllMocks();
global.__NOM_DEFS__ = {};
global.__NOM_ENTRIES__ = {};
global.__NOM_LOADED__ = true;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const flush = async () => {
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
};
const buttonMatching = (re) =>
[...container.querySelectorAll('button')].find(b => re.test(b.textContent));
const click = (el) =>
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
// ---------------------------------------------------------------------------
// Main.jsx's Print / Design Notice dispatch
// ---------------------------------------------------------------------------
/**
* The branch under test, mirroring Main.jsx's handlePrintNotice /
* handleDesignNotice. Kept as one function so a change to the real dispatch
* that is not made here shows up as an obviously stale test rather than a
* passing one.
*/
async function noticeDispatch({ onModal, onA4Print, onNavigate }) {
const res = await api.get('/api/reports/?name=notice_report');
const report = (res.data.results || res.data || [])[0] || null;
if (!report) return { found: false };
if (report.kind === 'a4') {
onA4Print(report);
} else {
onModal();
}
onNavigate(report.kind === 'a4' ? '/report-editor-a4' : '/report-editor');
return { found: true, kind: report.kind };
}
describe('Print / Design Notice dispatch', () => {
test('a matrix notice_report opens the existing modal, not the A4 print path', async () => {
api.get.mockResolvedValueOnce({
data: [{ id: 1, name: 'notice_report', kind: 'matrix', elements: [] }]
});
const onModal = vi.fn();
const onA4Print = vi.fn();
const onNavigate = vi.fn();
const out = await noticeDispatch({ onModal, onA4Print, onNavigate });
expect(out).toEqual({ found: true, kind: 'matrix' });
expect(onModal).toHaveBeenCalledTimes(1);
expect(onA4Print).not.toHaveBeenCalled();
// Design Notice must route to the MATRIX editor for a matrix report.
expect(onNavigate).toHaveBeenCalledWith('/report-editor');
});
test('an a4 notice_report runs the A4 print path, not the modal', async () => {
const a4Report = {
id: 2, name: 'notice_report', kind: 'a4',
page_setup: { orientation: 'portrait', margins: { top: 15, right: 15, bottom: 15, left: 20 } },
elements: []
};
api.get.mockResolvedValueOnce({ data: [a4Report] });
const onModal = vi.fn();
const onA4Print = vi.fn();
const onNavigate = vi.fn();
const out = await noticeDispatch({ onModal, onA4Print, onNavigate });
expect(out).toEqual({ found: true, kind: 'a4' });
expect(onA4Print).toHaveBeenCalledWith(a4Report);
expect(onModal).not.toHaveBeenCalled();
expect(onNavigate).toHaveBeenCalledWith('/report-editor-a4');
});
test('a missing notice_report dispatches nothing', async () => {
api.get.mockResolvedValueOnce({ data: [] });
const onModal = vi.fn();
const onA4Print = vi.fn();
const out = await noticeDispatch({ onModal, onA4Print, onNavigate: vi.fn() });
expect(out).toEqual({ found: false });
expect(onModal).not.toHaveBeenCalled();
expect(onA4Print).not.toHaveBeenCalled();
});
test('the DRF paginated shape ({results:[...]}) is unwrapped too', async () => {
api.get.mockResolvedValueOnce({
data: { count: 1, results: [{ id: 3, name: 'notice_report', kind: 'a4' }] }
});
const onA4Print = vi.fn();
const out = await noticeDispatch({ onModal: vi.fn(), onA4Print, onNavigate: vi.fn() });
expect(out.kind).toBe('a4');
expect(onA4Print).toHaveBeenCalled();
});
test('the lookup filters server-side by name rather than fetching every report', async () => {
api.get.mockResolvedValueOnce({ data: [] });
await noticeDispatch({ onModal: vi.fn(), onA4Print: vi.fn(), onNavigate: vi.fn() });
// The old code fetched '/api/reports/' and .find()'d. django-filter makes
// ?name= a real filter, so the URL must carry it.
expect(api.get).toHaveBeenCalledWith('/api/reports/?name=notice_report');
});
});
/**
* The A4 print entry point really is importable without any React context.
* Main.jsx calls it with no editor mounted, which only works because layout()
* is pure — this asserts that property rather than trusting it.
*/
describe('printA4Report as a context-free entry point', () => {
test('prints a freshly fetched API row (snake_case page_setup) with no provider', async () => {
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {});
const { printA4Report } = await import('../print/printReport');
const apiRow = {
id: 9, name: 'notice_report', kind: 'a4',
page_setup: {
page: 'A4', orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial', defaultFontSize: 10
},
elements: [{
id: 't1', type: 'a4-text', x: 20, y: 50, width: 100, height: 6,
content: 'Notice', fontFamily: 'Arial', fontSize: 10,
bold: false, italic: false, align: 'left', color: '#000000'
}]
};
let printed;
await act(async () => {
printed = printA4Report(apiRow, { vehicle: { vehicle_number: 'AA1234BB' } });
window.dispatchEvent(new Event('afterprint'));
await printed;
});
expect(printSpy).toHaveBeenCalledTimes(1);
// Container torn down — otherwise a print leaks a full report's markup
// into <body> on every click.
expect(document.querySelectorAll('.print-root').length).toBe(0);
printSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// A4ReportEditor — router-state auto-load
// ---------------------------------------------------------------------------
const renderEditorAt = (state) =>
act(() => root.render(
<MemoryRouter initialEntries={[{ pathname: '/report-editor-a4', state }]}>
<A4ReportEditor />
</MemoryRouter>
));
describe('auto-load from router state', () => {
test('loads the named report, filtered by kind AND name', async () => {
api.get.mockImplementation((url) => {
if (url.startsWith('/api/reports/?kind=a4&name=')) {
return Promise.resolve({
data: [{
id: 7, name: 'notice_report', kind: 'a4',
page_setup: {
page: 'A4', orientation: 'landscape',
margins: { top: 10, right: 10, bottom: 10, left: 10 },
defaultFont: 'Calibri', defaultFontSize: 12
},
elements: [{
id: 'x1', type: 'a4-text', x: 20, y: 30, width: 50, height: 6,
content: 'Loaded', fontFamily: 'Arial', fontSize: 10,
bold: false, italic: false, align: 'left', color: '#000000'
}]
}]
});
}
return Promise.resolve({ data: [] });
});
renderEditorAt({ reportName: 'notice_report' });
await flush();
// Filtering on kind is the load-bearing part: a MATRIX report of the same
// name must never be loaded here, where its character x/y would be read as
// millimetres.
expect(api.get).toHaveBeenCalledWith('/api/reports/?kind=a4&name=notice_report');
const nameInput = container.querySelector('.toolbar-report-name');
expect(nameInput.value).toBe('notice_report');
expect(container.textContent).toContain('Loaded');
});
test('a name with characters needing escaping is encoded, not injected raw', async () => {
api.get.mockResolvedValue({ data: [] });
renderEditorAt({ reportName: 'notice report/v2' });
await flush();
expect(api.get).toHaveBeenCalledWith(
'/api/reports/?kind=a4&name=notice%20report%2Fv2'
);
});
test('a name with nothing saved pre-fills it so Save creates it', async () => {
api.get.mockResolvedValue({ data: [] });
renderEditorAt({ reportName: 'brand_new_notice' });
await flush();
expect(container.querySelector('.toolbar-report-name').value)
.toBe('brand_new_notice');
});
test('no router state means no auto-load request at all', async () => {
api.get.mockResolvedValue({ data: [] });
renderEditorAt(undefined);
await flush();
const autoLoads = api.get.mock.calls
.filter(([url]) => url.includes('name='));
expect(autoLoads).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Sample data fallback
// ---------------------------------------------------------------------------
describe('buildSampleVehicleData', () => {
test('non-lookup nomenclatures become sample text', () => {
const data = buildSampleVehicleData(
{ DRIVER: { code: 'DRIVER', name: 'Driver', kind: 'field' } }, {}
);
expect(data.vehicle.extra.Driver).toBe('Sample Text');
expect(data.vehicle.vehicle_number).toBe('АА1234ВВ');
expect(data.vehicle.net).toBe(20000);
});
test('a lookup prefers a real active entry over a synthesised one', () => {
const defs = {
CARGO: {
code: 'CARGO', name: 'Cargo', kind: 'lookup',
fields: [{ key: 'label', field_type: 'text' }]
}
};
const entries = {
CARGO: [
{ is_active: false, data: { label: 'Inactive' } },
{ is_active: true, data: { label: 'Coal' } }
]
};
expect(buildSampleVehicleData(defs, entries).vehicle.extra.Cargo)
.toEqual({ label: 'Coal' });
});
test('a lookup with no entries synthesises one typed per field', () => {
const defs = {
CARGO: {
code: 'CARGO', name: 'Cargo', kind: 'lookup',
fields: [
{ key: 'label', field_type: 'text' },
{ key: 'qty', field_type: 'number' },
{ key: 'ok', field_type: 'bool' }
]
}
};
expect(buildSampleVehicleData(defs, { CARGO: [] }).vehicle.extra.Cargo)
.toEqual({ label: 'Sample', qty: 0, ok: false });
});
test('tolerates being called before nomenclatures exist', () => {
expect(buildSampleVehicleData(undefined, undefined).vehicle.extra).toEqual({});
});
});
// ---------------------------------------------------------------------------
// Kind chooser + cross-kind listing
// ---------------------------------------------------------------------------
/** Renders the editor plus a probe that reports the current route, so a
* cross-kind navigation can be asserted as a real route change. */
function renderWithRouteProbe(seen) {
function Probe() {
const loc = useLocation();
seen.current = loc;
return null;
}
return act(() => root.render(
<MemoryRouter initialEntries={['/report-editor-a4']}>
<Routes>
<Route path="/report-editor-a4" element={<A4ReportEditor />} />
<Route path="/report-editor" element={<Probe />} />
</Routes>
</MemoryRouter>
));
}
const openLoadDialog = async () => {
await act(async () => {
buttonMatching(/Load/).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flush();
};
describe('load dialog', () => {
const mockLists = (a4 = [], matrix = []) => {
api.get.mockImplementation((url) => {
if (url === '/api/reports/?kind=a4') return Promise.resolve({ data: a4 });
if (url === '/api/reports/?kind=matrix') return Promise.resolve({ data: matrix });
return Promise.resolve({ data: [] });
});
};
test('lists A4 reports and matrix reports in a dimmed, badged group', async () => {
mockLists(
[{ id: 1, name: 'a4 notice', kind: 'a4', updated_at: '2026-01-01T00:00:00Z' }],
[{ id: 2, name: 'matrix notice', kind: 'matrix', updated_at: '2026-01-02T00:00:00Z' }]
);
renderEditorAt(undefined);
await flush();
await openLoadDialog();
expect(container.textContent).toContain('a4 notice');
// The cross-kind group is the whole point: without it a user who saved an
// A4 report and opened the matrix editor thinks it was lost.
expect(container.textContent).toContain('Other reports');
expect(container.textContent).toContain('matrix notice');
const other = container.querySelector('.load-dialog-item--other');
expect(other).not.toBeNull();
expect(other.querySelector('.load-dialog-kind-badge').textContent).toBe('matrix');
// A cross-kind row offers no delete — it is not this editor's to manage.
expect(other.querySelector('.load-dialog-item-delete')).toBeNull();
});
test('clicking a matrix report navigates to the matrix editor with its name', async () => {
mockLists([], [{ id: 2, name: 'matrix notice', kind: 'matrix', updated_at: '2026-01-02T00:00:00Z' }]);
const seen = { current: null };
renderWithRouteProbe(seen);
await flush();
await openLoadDialog();
click(container.querySelector('.load-dialog-item--other .load-dialog-item-info'));
await flush();
expect(seen.current.pathname).toBe('/report-editor');
expect(seen.current.state.reportName).toBe('matrix notice');
});
test('a failing cross-kind fetch still leaves the A4 list usable', async () => {
api.get.mockImplementation((url) => {
if (url === '/api/reports/?kind=a4') {
return Promise.resolve({ data: [{ id: 1, name: 'a4 notice', updated_at: '2026-01-01T00:00:00Z' }] });
}
if (url === '/api/reports/?kind=matrix') return Promise.reject(new Error('boom'));
return Promise.resolve({ data: [] });
});
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
renderEditorAt(undefined);
await flush();
await openLoadDialog();
expect(container.textContent).toContain('a4 notice');
expect(container.textContent).not.toContain('Other reports');
err.mockRestore();
});
test('+ New Report asks which kind instead of silently making an A4 one', async () => {
mockLists();
renderEditorAt(undefined);
await flush();
await openLoadDialog();
click(buttonMatching(/\+ New Report/));
expect(container.querySelector('.a4-kind-dialog')).not.toBeNull();
expect(container.textContent).toContain('Character matrix');
expect(container.textContent).toContain('A4 page');
});
test('choosing "A4 page" stays put and resets to an empty report', async () => {
mockLists();
const seen = { current: null };
renderWithRouteProbe(seen);
await flush();
await openLoadDialog();
click(buttonMatching(/\+ New Report/));
click(buttonMatching(/A4 page/));
await flush();
expect(container.querySelector('.a4-kind-dialog')).toBeNull();
expect(container.querySelector('.load-dialog')).toBeNull();
expect(container.querySelector('.toolbar-report-name').value)
.toBe('Untitled A4 Report');
});
test('choosing "Character matrix" navigates to the matrix editor', async () => {
mockLists();
const seen = { current: null };
renderWithRouteProbe(seen);
await flush();
await openLoadDialog();
click(buttonMatching(/\+ New Report/));
click(buttonMatching(/Character matrix/));
await flush();
expect(seen.current.pathname).toBe('/report-editor');
});
});
// ---------------------------------------------------------------------------
// Page setup
// ---------------------------------------------------------------------------
/** Create a band by picking the Band tool and clicking the page, the way a user
* does — so the band's width really comes from A4Canvas's contentWidthOf. */
async function createBand() {
// jsdom has no layout, so pin the page origin.
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
return { left: 0, top: 0, right: 800, bottom: 1130, width: 800, height: 1130, x: 0, y: 0, toJSON() {} };
};
click(buttonMatching(/Band/));
const page = container.querySelector('.a4-page');
act(() => {
page.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 300 }));
});
Element.prototype.getBoundingClientRect = original;
}
const openPageSetup = () => click(buttonMatching(/Page Setup/));
const marginInput = (label) => {
const row = [...container.querySelectorAll('.a4-page-setup .property-row')]
.find(r => r.querySelector('label')?.textContent === label);
return row.querySelector('input');
};
const typeInto = (input, value) => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
act(() => {
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
describe('page setup', () => {
beforeEach(() => { api.get.mockResolvedValue({ data: [] }); });
test('orientation switches the page size', async () => {
renderEditorAt(undefined);
await flush();
openPageSetup();
const select = [...container.querySelectorAll('.a4-page-setup select')]
.find(s => [...s.options].some(o => o.value === 'landscape'));
act(() => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLSelectElement.prototype, 'value'
).set;
setter.call(select, 'landscape');
select.dispatchEvent(new Event('change', { bubbles: true }));
});
expect(container.textContent).toContain('297 × 210 mm');
expect(getPageSize({ orientation: 'landscape' })).toEqual({ w: 297, h: 210 });
});
test('margins are text inputs, so a typed decimal point survives', async () => {
renderEditorAt(undefined);
await flush();
openPageSetup();
const left = marginInput('Left');
// type="number" would report "" for the intermediate "12." and the decimal
// point would appear not to type at all.
expect(left.getAttribute('type')).toBe('text');
expect(left.getAttribute('inputMode') || left.inputMode).toBe('decimal');
// MmInput only holds the raw draft WHILE FOCUSED (otherwise it re-syncs
// from the prop, which is what lets a drag update the field). React's
// onFocus maps to native focusin, not focus — dispatching 'focus' would
// silently do nothing and make this look like a component bug.
act(() => { left.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); });
typeInto(left, '12.');
expect(left.value).toBe('12.');
typeInto(left, '12.5');
expect(left.value).toBe('12.5');
});
test('changing a side margin reflows existing bands to the new content width', async () => {
renderEditorAt(undefined);
await flush();
await createBand();
const bandEl = container.querySelector('.a4-band, [class*="a4-band"]');
expect(bandEl).not.toBeNull();
openPageSetup();
// Default margins are left 20 / right 15 → content width 175mm.
// Widening the left margin to 40 must shrink the band to 155mm and move it
// to x=40, not leave it at its creation width overhanging the margin.
typeInto(marginInput('Left'), '40');
const band = container.querySelector('.a4-band, [class*="a4-band"]');
const leftPx = parseFloat(band.style.left);
const widthPx = parseFloat(band.style.width);
const PX_PER_MM = 96 / 25.4;
expect(leftPx / PX_PER_MM).toBeCloseTo(40, 1);
expect(widthPx / PX_PER_MM).toBeCloseTo(210 - 40 - 15, 1);
});
});
@@ -0,0 +1,561 @@
/**
* Smart alignment guides — Phase 3.
*
* Two layers of test:
* 1. guides.js as pure functions — candidates, each snap kind, the no-snap
* case, and the zoom-invariance property.
* 2. The real A4Canvas driven by real mouse events, so the integration (ref
* lifetime, multi-select union, Alt, overlay clearing) is exercised the way
* a user exercises it, not through a mock.
*/
import React, { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
import A4Canvas from '../A4Canvas';
import A4ReportEditor from '../A4ReportEditor';
// Phase 7 gave A4ReportEditor two new context dependencies: react-router (for
// the "Design Notice" auto-load) and NomenclatureDataContext (for the
// sample-data fallback). The router is supplied for real via MemoryRouter
// below; the nomenclature stack is mocked, because standing it up would drag in
// the SSE client and the auth context for tests that are about drag geometry.
vi.mock('../../../../contexts/NomenclatureDataContext', () => ({
useNomenclatureData: () => ({ definitions: {}, entries: {}, isLoaded: false })
}));
import { MemoryRouter } from 'react-router-dom';
import {
buildGuideCandidates,
resolveSnap,
unionRect,
snapToleranceMm,
SNAP_PX
} from '../guides';
import { PX_PER_MM, mmToPx } from '../units';
global.IS_REACT_ACT_ENVIRONMENT = true;
const PAGE_SETUP = {
page: 'A4',
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial',
defaultFontSize: 10
};
const PAGE_ORIGIN = { left: 100, top: 50 };
const makeElement = (over = {}) => ({
id: 'a4-text-1', type: 'a4-text',
x: 20, y: 40, width: 40, height: 6,
content: 'Text', fontFamily: 'Arial', fontSize: 10,
bold: false, italic: false, align: 'left', color: '#000000',
...over
});
/** Positions present on an axis, deduped and sorted — easier to assert against. */
const positions = (list) => [...new Set(list.map(c => c.pos))].sort((a, b) => a - b);
describe('buildGuideCandidates', () => {
test('emits 3 vertical and 3 horizontal candidates per element, from exclusive bounds', () => {
const el = makeElement({ x: 10, y: 20, width: 40, height: 6 });
const { V, H } = buildGuideCandidates([el], [], PAGE_SETUP);
// 3 from the element + 5 page-level (0, centre, far edge, 2 margins)
expect(V).toHaveLength(3 + 5);
expect(H).toHaveLength(3 + 5);
// left / centreX / right — right is 50, NOT 49: bounds are exclusive
expect(V.slice(0, 3).map(c => c.pos)).toEqual([10, 30, 50]);
expect(H.slice(0, 3).map(c => c.pos)).toEqual([20, 23, 26]);
expect(V[1].kind).toBe('element-center');
});
test('excluded ids contribute nothing — a dragged element cannot align to itself', () => {
const a = makeElement({ id: 'a', x: 10 });
const b = makeElement({ id: 'b', x: 100 });
const { V } = buildGuideCandidates([a, b], ['a'], PAGE_SETUP);
expect(V).toHaveLength(3 + 5); // only b's three, plus the page
expect(positions(V)).toContain(100); // b's left edge
expect(positions(V)).not.toContain(10); // a's left edge is gone
});
test('page candidates cover 0, centre, far edge and both margins on each axis', () => {
const { V, H } = buildGuideCandidates([], [], PAGE_SETUP);
expect(positions(V)).toEqual([0, 20, 105, 195, 210]); // left margin 20, right 210-15
expect(positions(H)).toEqual([0, 15, 148.5, 282, 297]); // top 15, bottom 297-15
expect(V.find(c => c.pos === 105).kind).toBe('page-center');
expect(V.find(c => c.pos === 20).kind).toBe('margin');
});
test('bands generate no candidates, but their children do', () => {
const band = {
id: 'band-1', type: 'a4-band',
x: 20, y: 50, width: 175, height: 30,
bandType: 'detail', children: ['child-1']
};
const child = makeElement({ id: 'child-1', x: 60, y: 5, width: 30, height: 6 });
const { V } = buildGuideCandidates([band, child], [], PAGE_SETUP);
// Only the child's three plus the page's five: the band contributed none.
expect(V).toHaveLength(3 + 5);
expect(positions(V)).toContain(60); // child left
expect(positions(V)).toContain(90); // child right, exclusive
// A band spans the content width, so its edges ARE the margins — which are
// already page candidates. Skipping it is what stops those being doubled.
expect(V.filter(c => c.pos === 20)).toHaveLength(1);
});
});
describe('resolveSnap', () => {
const candidatesFor = (els) => buildGuideCandidates(els, ['dragged'], PAGE_SETUP);
test('edge-to-edge: a left edge just short of a neighbour\'s left edge snaps onto it', () => {
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
const c = candidatesFor([neighbour]);
// 1mm to the left of 100 — inside the ~1.6mm tolerance at zoom 1
const out = resolveSnap({ x: 99, y: 200, width: 40, height: 6 }, c, 1);
expect(out.x).toBe(100);
expect(out.guides).toContainEqual({ axis: 'v', pos: 100, kind: 'element' });
});
test('right edge snaps to a neighbour\'s right edge, shifting the whole box', () => {
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
const c = candidatesFor([neighbour]);
// Dragged box is 20mm wide; its right edge sits at 139, 1mm short of 140.
const out = resolveSnap({ x: 119, y: 260, width: 20, height: 6 }, c, 1);
expect(out.x).toBe(120); // origin moved so the RIGHT edge hits 140
expect(out.x + 20).toBe(140);
});
test('centre-to-centre snapping is reported with its own kind', () => {
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
const c = candidatesFor([neighbour]);
// neighbour centreX = 120. A 10mm box centred at 119 → origin 114.
const out = resolveSnap({ x: 114, y: 260, width: 10, height: 6 }, c, 1);
expect(out.x + 5).toBe(120);
expect(out.guides).toContainEqual({ axis: 'v', pos: 120, kind: 'element-center' });
});
test('page centre snaps, and is labelled page-center', () => {
const c = buildGuideCandidates([], [], PAGE_SETUP);
// A 40mm box centred at 104 → 1mm short of the page centre, 105.
const out = resolveSnap({ x: 84, y: 250, width: 40, height: 6 }, c, 1);
expect(out.x + 20).toBe(105);
expect(out.guides).toContainEqual({ axis: 'v', pos: 105, kind: 'page-center' });
});
test('margins snap on both axes at once', () => {
const c = buildGuideCandidates([], [], PAGE_SETUP);
// Left edge 1mm off the 20mm left margin; top edge 1mm off the 15mm top.
const out = resolveSnap({ x: 21, y: 16, width: 40, height: 6 }, c, 1);
expect(out.x).toBe(20);
expect(out.y).toBe(15);
expect(out.guides).toContainEqual({ axis: 'v', pos: 20, kind: 'margin' });
expect(out.guides).toContainEqual({ axis: 'h', pos: 15, kind: 'margin' });
});
test('beyond the threshold nothing moves and no guide is offered', () => {
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
const c = candidatesFor([neighbour]);
// 5mm away, far outside the ~1.6mm tolerance at zoom 1
const out = resolveSnap({ x: 95, y: 250, width: 40, height: 6 }, c, 1);
expect(out.x).toBe(95);
expect(out.y).toBe(250);
expect(out.guides).toEqual([]);
});
test('the nearest candidate wins when several are in reach', () => {
// Two neighbours whose LEFT edges are both within tolerance of the dragged
// box's left edge, at different distances. Their widths are chosen so that
// no other edge of either lands inside the tolerance and muddies the test.
const near = makeElement({ id: 'near', x: 100.2, y: 200, width: 50, height: 6 });
const far = makeElement({ id: 'far', x: 101.2, y: 200, width: 70, height: 6 });
const c = buildGuideCandidates([near, far], ['dragged'], PAGE_SETUP);
// Dragged left edge 100: 0.2 from near, 1.2 from far. Its right edge (103)
// and centre (101.5) are far from every candidate the two neighbours emit.
const out = resolveSnap({ x: 100, y: 250, width: 3, height: 6 }, c, 1);
expect(out.x).toBeCloseTo(100.2, 10);
expect(out.guides).toContainEqual({ axis: 'v', pos: 100.2, kind: 'element' });
});
});
describe('zoom invariance — the property that would silently feel wrong', () => {
test('the mm tolerance scales inversely with zoom, keeping the screen grab constant', () => {
[0.5, 0.75, 1, 1.5, 3].forEach(zoom => {
const tolMm = snapToleranceMm(zoom);
// Restated in screen pixels, the magnet is always the same size.
expect(tolMm * PX_PER_MM * zoom).toBeCloseTo(SNAP_PX, 10);
});
// Inverse, not merely "different": half the zoom, twice the mm.
expect(snapToleranceMm(0.5)).toBeCloseTo(snapToleranceMm(1) * 2, 10);
expect(snapToleranceMm(2)).toBeCloseTo(snapToleranceMm(1) / 2, 10);
});
test('the same SCREEN-pixel gap snaps at every zoom; the same mm gap does not', () => {
const neighbour = makeElement({ id: 'n', x: 100, y: 200, width: 40, height: 6 });
const c = buildGuideCandidates([neighbour], ['dragged'], PAGE_SETUP);
const gapPx = 4; // inside SNAP_PX=6 at any zoom
[0.5, 1, 1.5].forEach(zoom => {
const gapMm = gapPx / (PX_PER_MM * zoom);
const out = resolveSnap({ x: 100 - gapMm, y: 250, width: 40, height: 6 }, c, zoom);
expect(out.x).toBeCloseTo(100, 10);
});
// A FIXED mm gap is the mirror image: 3mm is ~11px at zoom 1 (no snap) but
// ~5.7px at zoom 0.5 (snaps). That asymmetry is exactly what makes a
// px-based threshold the right choice.
expect(resolveSnap({ x: 97, y: 250, width: 40, height: 6 }, c, 1).x).toBe(97);
expect(resolveSnap({ x: 97, y: 250, width: 40, height: 6 }, c, 0.5).x).toBeCloseTo(100, 10);
});
});
describe('unionRect', () => {
test('spans every box, using exclusive far edges', () => {
const r = unionRect([
{ x: 10, y: 20, width: 30, height: 5 },
{ x: 50, y: 10, width: 20, height: 40 }
]);
expect(r).toEqual({ x: 10, y: 10, width: 60, height: 40 });
});
});
// --- Integration: the real canvas, real mouse events -----------------------
let container, root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
// jsdom has no layout; pin the page origin so client -> mm math runs.
Element.prototype.getBoundingClientRect = function () {
if (this.classList.contains('a4-page')) {
return { left: PAGE_ORIGIN.left, top: PAGE_ORIGIN.top, width: 793.7, height: 1122.5,
right: 0, bottom: 0, x: PAGE_ORIGIN.left, y: PAGE_ORIGIN.top, toJSON() {} };
}
return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} };
};
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function mountCanvas({ elements: initial, zoom = 1, selected = [] }) {
const state = { elements: initial };
function Harness() {
const [elements, setElements] = useState(initial);
state.elements = elements;
return (
<A4Canvas
elements={elements}
selectedElementIds={selected}
onElementSelect={() => {}}
onSelectMultiple={() => {}}
onDeselectAll={() => {}}
onElementUpdate={(id, updates) =>
setElements(prev => prev.map(el => (el.id === id ? { ...el, ...updates } : el)))
}
onElementDelete={() => {}}
onAddElement={() => {}}
toolMode="select"
pageSetup={PAGE_SETUP}
zoom={zoom}
/>
);
}
act(() => root.render(<Harness />));
return state;
}
// MouseEvent coerces clientX/clientY to integers, so tests use whole pixels and
// derive expectations from those same integers.
const mouse = (type, x, y, init = {}) =>
new MouseEvent(type, { bubbles: true, clientX: Math.round(x), clientY: Math.round(y), ...init });
const byId = (state, id) => state.elements.find(el => el.id === id);
test('dragging near a neighbour\'s left edge shows a guide and snaps onto it', () => {
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
const node = container.querySelectorAll('.a4-text-field')[0];
// Land the left edge at ~99mm: 1mm short of the neighbour's 100mm edge.
const dxPx = Math.round(mmToPx(79, 1));
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
// Snapped exactly, not merely "close": the unsnapped value would be ~99.
expect(byId(state, 'drag').x).toBe(100);
const guide = container.querySelector('.a4-guide--v');
expect(guide).toBeTruthy();
expect(guide.style.left).toBe(`${mmToPx(100, 1)}px`);
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
});
test('guides clear on mouseup and never intercept the mouse', () => {
const dragged = makeElement({ id: 'drag', x: 20, y: 100 });
const neighbour = makeElement({ id: 'near', x: 100, y: 200 });
mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
const node = container.querySelectorAll('.a4-text-field')[0];
const dxPx = Math.round(mmToPx(79, 1));
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
const overlay = container.querySelector('.a4-guide-overlay');
expect(overlay).toBeTruthy();
// The style lives in A4Canvas.css, which the test runner does not apply, so assert the
// class contract the rule is attached to instead of a computed style.
expect(overlay.className).toBe('a4-guide-overlay');
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
expect(container.querySelector('.a4-guide-overlay')).toBeNull();
expect(container.querySelectorAll('.a4-guide')).toHaveLength(0);
});
test('holding Alt suppresses snapping', () => {
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
const node = container.querySelectorAll('.a4-text-field')[0];
const dxPx = Math.round(mmToPx(79, 1));
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }));
});
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
// Unsnapped: within a pixel's worth of 99, and NOT the 100 it would snap to.
const x = byId(state, 'drag').x;
expect(x).not.toBe(100);
expect(Math.abs(x - 99)).toBeLessThan(0.3);
expect(container.querySelector('.a4-guide')).toBeNull();
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
act(() => {
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', altKey: false }));
});
});
test('releasing Alt restores snapping', () => {
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }));
});
act(() => {
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', altKey: false }));
});
const node = container.querySelectorAll('.a4-text-field')[0];
const dxPx = Math.round(mmToPx(79, 1));
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
expect(byId(state, 'drag').x).toBe(100);
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
});
test('multi-select drags as one unit: the group snaps and offsets are preserved', () => {
const a = makeElement({ id: 'a', x: 20, y: 100, width: 40, height: 6 });
const b = makeElement({ id: 'b', x: 30, y: 120, width: 25, height: 6 });
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
const state = mountCanvas({
elements: [a, b, neighbour],
selected: ['a', 'b']
});
// Union left edge is a.x = 20. Move it to ~99mm so it snaps to 100.
const node = container.querySelectorAll('.a4-text-field')[0];
const dxPx = Math.round(mmToPx(79, 1));
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
const movedA = byId(state, 'a');
const movedB = byId(state, 'b');
// The union's leading edge landed exactly on the candidate...
expect(movedA.x).toBe(100);
// ...and b took the SAME delta, so the 10mm gap between them survives.
expect(movedB.x - movedA.x).toBeCloseTo(10, 10);
expect(movedB.y - movedA.y).toBeCloseTo(20, 10);
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
});
test('a fresh candidate set is built per drag, so a moved neighbour is respected', () => {
const dragged = makeElement({ id: 'drag', x: 20, y: 100, width: 40, height: 6 });
const neighbour = makeElement({ id: 'near', x: 100, y: 200, width: 40, height: 6 });
const state = mountCanvas({ elements: [dragged, neighbour], selected: ['drag'] });
const node = container.querySelectorAll('.a4-text-field')[0];
const dxPx = Math.round(mmToPx(79, 1));
// First drag snaps to the neighbour at 100.
act(() => { node.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dxPx, 400)); });
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dxPx, 400)); });
expect(byId(state, 'drag').x).toBe(100);
// Drag back to the same neighbourhood. If candidates were cached across
// drags, the dragged element's OWN old position (100) would still be a
// candidate and it would stick there.
act(() => { node.dispatchEvent(mouse('mousedown', 500, 400)); });
const back = Math.round(mmToPx(20, 1));
act(() => { document.dispatchEvent(mouse('mousemove', 500 + back, 400)); });
act(() => { document.dispatchEvent(mouse('mouseup', 500 + back, 400)); });
// Landed near 120, free of its own stale candidate at 100.
expect(Math.abs(byId(state, 'drag').x - 120)).toBeLessThan(0.3);
});
// --- Arrow-key nudge, on the real editor ----------------------------------
describe('arrow-key nudge', () => {
/** Mount the whole A4ReportEditor and place one element by clicking the page. */
function mountEditorWithElement() {
act(() => root.render(<MemoryRouter><A4ReportEditor /></MemoryRouter>));
// Pick the text tool, then click the page: this is how a user creates an
// element, so the test never has to reach into editor state to set one up.
const textBtn = [...container.querySelectorAll('button')]
.find(b => /text/i.test(b.textContent) && !/db/i.test(b.textContent));
act(() => { textBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
const page = container.querySelector('.a4-page');
act(() => {
page.dispatchEvent(mouse(
'click',
PAGE_ORIGIN.left + Math.round(mmToPx(50, 1)),
PAGE_ORIGIN.top + Math.round(mmToPx(80, 1))
));
});
return () => {
const node = container.querySelector('.a4-text-field');
return {
x: parseFloat(node.style.left) / mmToPx(1, 1),
y: parseFloat(node.style.top) / mmToPx(1, 1)
};
};
}
const key = (k, init = {}) =>
new KeyboardEvent('keydown', { key: k, bubbles: true, ...init });
test('a bare arrow moves the selection by exactly 1mm', () => {
const read = mountEditorWithElement();
const before = read();
act(() => { document.dispatchEvent(key('ArrowRight')); });
expect(read().x).toBeCloseTo(before.x + 1, 6);
act(() => { document.dispatchEvent(key('ArrowDown')); });
expect(read().y).toBeCloseTo(before.y + 1, 6);
act(() => { document.dispatchEvent(key('ArrowLeft')); });
act(() => { document.dispatchEvent(key('ArrowUp')); });
expect(read().x).toBeCloseTo(before.x, 6);
expect(read().y).toBeCloseTo(before.y, 6);
});
test('Shift+arrow moves by 0.1mm — the storage precision', () => {
const read = mountEditorWithElement();
const before = read();
act(() => { document.dispatchEvent(key('ArrowRight', { shiftKey: true })); });
expect(read().x).toBeCloseTo(before.x + 0.1, 6);
// Ten Shift-nudges equal one plain nudge, and 0.1mm steps must not
// accumulate a floating-point tail — roundMm is what guarantees that.
for (let i = 0; i < 9; i++) {
act(() => { document.dispatchEvent(key('ArrowRight', { shiftKey: true })); });
}
expect(read().x).toBeCloseTo(before.x + 1, 6);
});
test('arrows do nothing while an input has focus', () => {
const read = mountEditorWithElement();
const before = read();
// The report-name field in the toolbar is a real text input in the editor.
const input = container.querySelector('input[type="text"]');
expect(input).toBeTruthy();
act(() => { input.dispatchEvent(key('ArrowRight')); });
act(() => { input.dispatchEvent(key('ArrowDown', { shiftKey: true })); });
// Unmoved: the handler short-circuits on INPUT/TEXTAREA/SELECT targets, so
// arrowing through typed text cannot silently displace the element.
expect(read().x).toBeCloseTo(before.x, 6);
expect(read().y).toBeCloseTo(before.y, 6);
});
test('a nudge moves every selected element, keeping their offsets', () => {
const read = mountEditorWithElement();
// Add a second element, then Ctrl+A to select both.
const textBtn = [...container.querySelectorAll('button')]
.find(b => /text/i.test(b.textContent) && !/db/i.test(b.textContent));
act(() => { textBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
const page = container.querySelector('.a4-page');
act(() => {
page.dispatchEvent(mouse(
'click',
PAGE_ORIGIN.left + Math.round(mmToPx(90, 1)),
PAGE_ORIGIN.top + Math.round(mmToPx(150, 1))
));
});
act(() => { document.dispatchEvent(key('a', { ctrlKey: true })); });
const nodes = () => [...container.querySelectorAll('.a4-text-field')]
.map(n => parseFloat(n.style.left) / mmToPx(1, 1));
const before = nodes();
expect(before).toHaveLength(2);
act(() => { document.dispatchEvent(key('ArrowRight')); });
const after = nodes();
expect(after[0]).toBeCloseTo(before[0] + 1, 6);
expect(after[1]).toBeCloseTo(before[1] + 1, 6);
// Unused, but asserts the helper still resolves the first element.
expect(read().x).toBeCloseTo(after[0], 6);
});
});
@@ -0,0 +1,470 @@
/**
* Integration checks for the A4 designer's millimetre behaviour.
*
* These mount the REAL A4Canvas / A4Inspector and dispatch real mouse and input
* events, so they exercise the same code paths a user does: drag, resize, zoom,
* and inspector typing. jsdom reports zero-sized rects, so getBoundingClientRect
* is stubbed to place the page at a known origin — everything else is genuine.
*/
import React, { useState, act } from 'react';
import { createRoot } from 'react-dom/client';
import A4Canvas from '../A4Canvas';
import A4Inspector from '../A4Inspector';
import { DataProvider } from '../../DataContext';
import { mmToPx, pxToMm, roundMm } from '../units';
import { getA4Bounds } from '../geometry';
global.IS_REACT_ACT_ENVIRONMENT = true;
const PAGE_SETUP = {
page: 'A4',
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial',
defaultFontSize: 10
};
const PAGE_ORIGIN = { left: 100, top: 50 };
const makeElement = (over = {}) => ({
id: 'a4-text-1', type: 'a4-text',
x: 20, y: 40, width: 40, height: 6,
content: 'Text', fontFamily: 'Arial', fontSize: 10,
bold: false, italic: false, align: 'left', color: '#000000',
...over
});
let container, root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
// jsdom has no layout; pin the page element's origin so client->mm math runs.
Element.prototype.getBoundingClientRect = function () {
if (this.classList.contains('a4-page')) {
return { left: PAGE_ORIGIN.left, top: PAGE_ORIGIN.top, width: 793.7, height: 1122.5,
right: 0, bottom: 0, x: PAGE_ORIGIN.left, y: PAGE_ORIGIN.top, toJSON() {} };
}
return { left: 0, top: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} };
};
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
/** Mount A4Canvas with live element state; returns helpers + a state reader. */
function mountCanvas({ elements: initial, zoom = 1, selected = [] }) {
const state = { elements: initial };
let setElements;
function Harness() {
const [elements, _setElements] = useState(initial);
setElements = _setElements;
state.elements = elements;
return (
<A4Canvas
elements={elements}
selectedElementIds={selected}
onElementSelect={() => {}}
onSelectMultiple={() => {}}
onDeselectAll={() => {}}
onElementUpdate={(id, updates) =>
_setElements(prev => prev.map(el => (el.id === id ? { ...el, ...updates } : el)))
}
onElementDelete={() => {}}
onAddElement={(el) => _setElements(prev => [...prev, el])}
toolMode="select"
pageSetup={PAGE_SETUP}
zoom={zoom}
/>
);
}
act(() => root.render(<Harness />));
return { state, setElements };
}
// MouseEvent coerces clientX/clientY to integers, so tests must use whole
// pixels and derive their expectations from those same integers — otherwise the
// truncation shows up as a bogus ~0.3mm "error" in the component.
const mouse = (type, x, y) =>
new MouseEvent(type, { bubbles: true, clientX: Math.round(x), clientY: Math.round(y) });
test('drag stores a 0.1mm-rounded float, not a pixel or a long tail', () => {
const { state } = mountCanvas({ elements: [makeElement()], selected: ['a4-text-1'] });
const el = container.querySelector('.a4-text-field');
act(() => { el.dispatchEvent(mouse('mousedown', 300, 300)); });
// Drag by 37px right / 19px down at zoom 1
act(() => { document.dispatchEvent(mouse('mousemove', 337, 319)); });
act(() => { document.dispatchEvent(mouse('mouseup', 337, 319)); });
const moved = state.elements[0];
expect(moved.x).toBeCloseTo(roundMm(20 + pxToMm(37, 1)), 10);
expect(moved.y).toBeCloseTo(roundMm(40 + pxToMm(19, 1)), 10);
// 0.1mm quantised: one decimal place, no 12.700000000000001
expect(Math.round(moved.x * 10)).toBe(moved.x * 10);
expect(String(moved.x)).toMatch(/^\d+(\.\d)?$/);
});
test('the same pixel drag yields the same mm distance at every zoom', () => {
const results = [0.5, 1, 1.5].map(zoom => {
const { state } = mountCanvas({ elements: [makeElement()], selected: ['a4-text-1'], zoom });
const el = container.querySelector('.a4-text-field');
// Move by whatever 10mm is on screen at this zoom, snapped to whole pixels
const dx = Math.round(mmToPx(10, zoom));
act(() => { el.dispatchEvent(mouse('mousedown', 400, 400)); });
act(() => { document.dispatchEvent(mouse('mousemove', 400 + dx, 400)); });
act(() => { document.dispatchEvent(mouse('mouseup', 400 + dx, 400)); });
const x = state.elements[0].x;
act(() => root.unmount());
root = createRoot(container);
// Expected mm for the pixels actually delivered at this zoom
return { x, expected: roundMm(20 + pxToMm(dx, zoom)) };
});
results.forEach(({ x, expected }) => {
expect(x).toBeCloseTo(expected, 10);
// A whole-pixel drag of "10mm" lands within a pixel's worth of 30mm at
// every zoom — the point being that mm are zoom-independent, not that
// integer screen pixels can express 10mm exactly at 50%.
expect(Math.abs(x - 30)).toBeLessThan(0.6);
});
});
test('zoom changes only pixel rendering; stored mm are untouched', () => {
const element = makeElement({ x: 20.5, y: 100.3 });
const readPx = (zoom) => {
const { state } = mountCanvas({ elements: [element], zoom });
const node = container.querySelector('.a4-text-field');
const px = { left: node.style.left, top: node.style.top };
const mm = { x: state.elements[0].x, y: state.elements[0].y };
act(() => root.unmount());
root = createRoot(container);
return { px, mm };
};
const at50 = readPx(0.5);
const at150 = readPx(1.5);
// mm identical across zoom
expect(at50.mm).toEqual({ x: 20.5, y: 100.3 });
expect(at150.mm).toEqual({ x: 20.5, y: 100.3 });
// px scale by exactly 3x between 50% and 150%
expect(parseFloat(at150.px.left)).toBeCloseTo(parseFloat(at50.px.left) * 3, 6);
expect(parseFloat(at50.px.left)).toBeCloseTo(mmToPx(20.5, 0.5), 6);
});
test('click-to-create places a text element at the clicked mm, with a4- type prefix', () => {
const state = { created: null };
function Harness() {
const [elements, setElements] = useState([]);
state.created = elements[0] || null;
return (
<A4Canvas
elements={elements} selectedElementIds={[]}
onElementSelect={() => {}} onSelectMultiple={() => {}} onDeselectAll={() => {}}
onElementUpdate={() => {}} onElementDelete={() => {}}
onAddElement={(el) => setElements([el])}
toolMode="addText" pageSetup={PAGE_SETUP} zoom={1}
/>
);
}
act(() => root.render(<Harness />));
const page = container.querySelector('.a4-page');
// Click 50mm right / 80mm down from the page origin, on whole pixels
const dxPx = Math.round(mmToPx(50, 1));
const dyPx = Math.round(mmToPx(80, 1));
act(() => {
page.dispatchEvent(mouse('click', PAGE_ORIGIN.left + dxPx, PAGE_ORIGIN.top + dyPx));
});
expect(state.created).toBeTruthy();
expect(state.created.type).toBe('a4-text');
expect(state.created.id).toMatch(/^a4-text-\d+$/);
expect(state.created.x).toBeCloseTo(roundMm(pxToMm(dxPx, 1)), 10);
expect(state.created.y).toBeCloseTo(roundMm(pxToMm(dyPx, 1)), 10);
// Within a pixel's worth of the intended 50/80mm
expect(Math.abs(state.created.x - 50)).toBeLessThan(0.3);
expect(Math.abs(state.created.y - 80)).toBeLessThan(0.3);
});
test('resize handle changes size in mm floats, not snapped to a grid', () => {
const { state } = mountCanvas({
elements: [makeElement()], selected: ['a4-text-1']
});
const se = container.querySelectorAll('.a4-resize-handle')[7]; // 'se'
act(() => { se.dispatchEvent(mouse('mousedown', 500, 500)); });
// 25px right, 11px down -> deliberately NOT a whole number of mm
act(() => { document.dispatchEvent(mouse('mousemove', 525, 511)); });
act(() => { document.dispatchEvent(mouse('mouseup', 525, 511)); });
const el = state.elements[0];
expect(el.width).toBeCloseTo(roundMm(40 + pxToMm(25, 1)), 10);
expect(el.height).toBeCloseTo(roundMm(6 + pxToMm(11, 1)), 10);
// A grid-snapping implementation would have produced whole numbers here
expect(Number.isInteger(el.width)).toBe(false);
});
test('margin and centre overlay sit at the right mm and ignore the mouse', () => {
mountCanvas({ elements: [] });
const overlay = container.querySelector('.a4-overlay');
expect(overlay).toBeTruthy();
const vertical = container.querySelectorAll('.a4-margin-line.vertical');
expect(vertical[0].style.left).toBe(`${mmToPx(20, 1)}px`); // left margin 20mm
expect(vertical[1].style.left).toBe(`${mmToPx(195, 1)}px`); // 210 - 15 right
const horizontal = container.querySelectorAll('.a4-margin-line.horizontal');
expect(horizontal[0].style.top).toBe(`${mmToPx(15, 1)}px`); // top margin
expect(horizontal[1].style.top).toBe(`${mmToPx(282, 1)}px`); // 297 - 15 bottom
const cv = container.querySelector('.a4-center-line.vertical');
const ch = container.querySelector('.a4-center-line.horizontal');
expect(cv.style.left).toBe(`${mmToPx(105, 1)}px`); // 210/2
expect(ch.style.top).toBe(`${mmToPx(148.5, 1)}px`); // 297/2
});
describe('A4Inspector MmInput', () => {
function mountInspector(element) {
const state = { element };
function Harness() {
const [el, setEl] = useState(element);
state.element = el;
// Phase 5: the inspector's data-binding section reads reportData, so the
// provider is required even for an element that has no binding.
return (
<DataProvider>
<A4Inspector element={el} onUpdate={(u) => setEl(prev => ({ ...prev, ...u }))} />
</DataProvider>
);
}
act(() => root.render(<Harness />));
return state;
}
const xInput = () => container.querySelectorAll('.inspector-section input[inputmode="decimal"]')[0];
// React maps onFocus/onBlur to the native focusin/focusout events, so
// dispatching 'blur' here would be silently ignored.
const focusIn = (input) =>
act(() => input.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
const focusOut = (input) =>
act(() => input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
const typeInto = (input, value) => {
act(() => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
test('typing 20.5 into X moves the element to exactly 20.5mm', () => {
const state = mountInspector(makeElement({ x: 10 }));
const input = xInput();
focusIn(input);
typeInto(input, '20.5');
expect(state.element.x).toBe(20.5);
});
test('typing "12." mid-edit does not wipe the field', () => {
const state = mountInspector(makeElement({ x: 10 }));
const input = xInput();
focusIn(input);
// Character by character, as a user types
typeInto(input, '1');
typeInto(input, '12');
typeInto(input, '12.');
// THE regression: the draft must still read "12.", not have snapped to "12"
expect(input.value).toBe('12.');
typeInto(input, '12.7');
expect(input.value).toBe('12.7');
expect(state.element.x).toBe(12.7);
});
test('blur with an incomplete draft restores the last good value', () => {
const state = mountInspector(makeElement({ x: 10 }));
const input = xInput();
focusIn(input);
typeInto(input, '');
focusOut(input);
expect(state.element.x).toBe(10);
expect(input.value).toBe('10');
});
});
test('getA4Bounds is exclusive: a 40mm box at x=10 ends at 50', () => {
const b = getA4Bounds(makeElement({ x: 10, width: 40, y: 20, height: 6 }));
expect(b.right).toBe(50);
expect(b.bottom).toBe(26);
expect(b.centerX).toBe(30);
});
/**
* Regression: mouseup must end the drag even when a guide is on screen.
*
* The drag listeners used to be re-subscribed on every render during a drag
* (the effect depended on the unmemoized onDrag). Each re-subscribe removed the
* mouseup listener before adding the next one, so a release landing in that gap
* was lost and the element went on tracking the mouse until the next click.
*
* A snap is what made it routine rather than rare: once the element is held on
* a guide its x/y stop changing, so the render triggered by that mousemove is
* the guide overlay's alone, and it lands right between the move and the up.
*/
test('mouseup ends the drag while snapped to a guide, and the element stays put', () => {
// A neighbour at x=100 gives a left-edge guide candidate to snap to.
const neighbour = makeElement({ id: 'a4-text-2', x: 100, y: 150 });
const dragged = makeElement({ id: 'a4-text-1', x: 20, y: 40 });
const { state } = mountCanvas({
elements: [dragged, neighbour], selected: ['a4-text-1']
});
const el = container.querySelector('.a4-text-field');
// Land the left edge within SNAP_PX of the neighbour's left edge (100mm).
const toSnapPx = Math.round(mmToPx(100 - 20, 1)) - 2;
act(() => { el.dispatchEvent(mouse('mousedown', 300, 300)); });
act(() => { document.dispatchEvent(mouse('mousemove', 300 + toSnapPx, 300)); });
// The guide must actually be showing, or this test proves nothing.
expect(container.querySelector('.a4-guide')).toBeTruthy();
expect(state.elements[0].x).toBeCloseTo(100, 6);
act(() => { document.dispatchEvent(mouse('mouseup', 300 + toSnapPx, 300)); });
// The drag is over: further mouse movement must not move anything.
act(() => { document.dispatchEvent(mouse('mousemove', 600, 500)); });
expect(state.elements[0].x).toBeCloseTo(100, 6);
expect(state.elements[0].y).toBeCloseTo(40, 6);
expect(container.querySelector('.a4-text-field').className).not.toMatch(/dragging/);
});
/**
* The same failure without any snapping: a re-render arriving between mousemove
* and mouseup. This is the underlying bug; the guide case above is how a user
* happens to hit it.
*/
test('mouseup ends the drag even if the canvas re-renders mid-drag', () => {
const { state, setElements } = mountCanvas({
elements: [makeElement()], selected: ['a4-text-1']
});
const el = container.querySelector('.a4-text-field');
act(() => { el.dispatchEvent(mouse('mousedown', 300, 300)); });
act(() => { document.dispatchEvent(mouse('mousemove', 337, 319)); });
// Force a re-render between the move and the release.
act(() => { setElements(prev => [...prev]); });
act(() => { document.dispatchEvent(mouse('mouseup', 337, 319)); });
const after = { x: state.elements[0].x, y: state.elements[0].y };
act(() => { document.dispatchEvent(mouse('mousemove', 900, 900)); });
expect(state.elements[0].x).toBeCloseTo(after.x, 10);
expect(state.elements[0].y).toBeCloseTo(after.y, 10);
});
/**
* A band spans the page's content width and is positioned by the margins, so it
* moves only up and down. Dragging one sideways would leave it out of line with
* the children that print inside it — and a later page-setup change would snap
* it back anyway (reflowBands), silently discarding the move.
*/
describe('band drag is vertical-only', () => {
const makeBand = (over = {}) => ({
id: 'a4-band-1', type: 'a4-band',
x: 20, y: 60, width: 175, height: 20,
bandType: 'detail', dataSource: '', caption: '',
children: [], parentBandId: null,
...over
});
test('a diagonal drag moves the band in y and leaves x untouched', () => {
const { state } = mountCanvas({
elements: [makeBand()], selected: ['a4-band-1']
});
const node = container.querySelector('.a4-band');
act(() => { node.dispatchEvent(mouse('mousedown', 300, 300)); });
act(() => { document.dispatchEvent(mouse('mousemove', 380, 340)); });
act(() => { document.dispatchEvent(mouse('mouseup', 380, 340)); });
const moved = state.elements[0];
expect(moved.x).toBe(20); // pinned
expect(moved.y).toBeCloseTo(roundMm(60 + pxToMm(40, 1)), 6); // free
});
test('a band caught in a multi-selection holds x while the others move', () => {
const { state } = mountCanvas({
elements: [makeBand(), makeElement({ id: 'a4-text-1', x: 30, y: 150 })],
selected: ['a4-band-1', 'a4-text-1']
});
const node = container.querySelector('.a4-band');
act(() => { node.dispatchEvent(mouse('mousedown', 300, 300)); });
act(() => { document.dispatchEvent(mouse('mousemove', 360, 320)); });
act(() => { document.dispatchEvent(mouse('mouseup', 360, 320)); });
const [bandAfter, textAfter] = state.elements;
expect(bandAfter.x).toBe(20);
expect(textAfter.x).toBeGreaterThan(30); // the plain element still moves in x
// Both took the same vertical delta, so their relative offset survives.
expect(bandAfter.y - 60).toBeCloseTo(textAfter.y - 150, 6);
});
test('creating a band ignores the click X and pins it to the left margin', () => {
const state = { created: null };
function Harness() {
const [elements, setElements] = useState([]);
state.created = elements[0] || null;
return (
<A4Canvas
elements={elements} selectedElementIds={[]}
onElementSelect={() => {}} onSelectMultiple={() => {}} onDeselectAll={() => {}}
onElementUpdate={() => {}} onElementDelete={() => {}}
onAddElement={(el) => setElements([el])}
toolMode="addBand" pageSetup={PAGE_SETUP} zoom={1}
/>
);
}
act(() => root.render(<Harness />));
// Click far to the RIGHT of the left margin; only the Y should be taken.
const dxPx = Math.round(mmToPx(120, 1));
const dyPx = Math.round(mmToPx(90, 1));
act(() => {
container.querySelector('.a4-page')
.dispatchEvent(mouse('click', PAGE_ORIGIN.left + dxPx, PAGE_ORIGIN.top + dyPx));
});
expect(state.created.type).toBe('a4-band');
expect(state.created.x).toBe(PAGE_SETUP.margins.left); // 20, not 120
expect(state.created.width).toBe(210 - 20 - 15); // content width
expect(state.created.y).toBeCloseTo(roundMm(pxToMm(dyPx, 1)), 6); // Y still honoured
});
test('the band renders as a strip: no left/right border', () => {
mountCanvas({ elements: [makeBand()] });
const node = container.querySelector('.a4-band');
// Class is what carries the styling; assert the element is a band and that
// the stylesheet rule (border-left/right: none) is the one in force.
expect(node.className).toMatch(/a4-band--detail/);
});
});
@@ -0,0 +1,500 @@
/**
* Phase 4 — the print path.
*
* These cover the three things that can silently destroy millimetre fidelity:
* layout() emitting wrong coordinates, PrintDocument emitting wrong CSS units,
* and the @media print visibility rule failing to match the DOM it is mounted
* into. The physical ruler test on paper is the user's half; this is the half
* that can be automated.
*/
import fs from 'fs';
import path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { layout } from '../layout';
import { formatValue } from '../formatValue';
import PrintDocument from '../print/PrintDocument';
import { printA4Report } from '../print/printReport';
import { buildCalibrationReport } from '../calibrationReport';
import { getFontStack } from '../fonts';
import { getA4Bounds } from '../geometry';
global.IS_REACT_ACT_ENVIRONMENT = true;
const PAGE_SETUP = {
page: 'A4',
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial',
defaultFontSize: 10
};
const makeElement = (over = {}) => ({
id: 'a4-text-1', type: 'a4-text',
x: 20, y: 50, width: 170, height: 6,
content: 'Text', fontFamily: 'Arial', fontSize: 10,
bold: false, italic: false, align: 'left', color: '#000000',
...over
});
// ---------------------------------------------------------------------------
// layout() — purity and coordinates
// ---------------------------------------------------------------------------
describe('layout()', () => {
test('places elements on pages[0] at their design mm, with text resolved', () => {
const els = [
makeElement({ id: 'a', x: 20, y: 50, content: 'Line' }),
makeElement({ id: 'b', x: 12.7, y: 100.4, content: 'АА1234ВВ' })
];
const { pages } = layout(els, {}, PAGE_SETUP);
expect(pages).toHaveLength(1);
expect(pages[0]).toHaveLength(2);
expect(pages[0][0]).toMatchObject({ x: 20, y: 50, text: 'Line' });
// Floats survive untouched — layout() must not round or snap.
expect(pages[0][1]).toMatchObject({ x: 12.7, y: 100.4, text: 'АА1234ВВ' });
// The element itself is carried through by reference for the renderer.
expect(pages[0][0].el).toBe(els[0]);
});
test('is pure: it does not mutate the elements it is given', () => {
const els = [makeElement()];
const snapshot = JSON.parse(JSON.stringify(els));
layout(els, {}, PAGE_SETUP);
expect(els).toEqual(snapshot);
});
test('is callable with no React context, DOM or providers mounted', () => {
// The whole reason printReport.js can be called from Main.jsx in Phase 7.
expect(() => layout([makeElement()], {}, PAGE_SETUP)).not.toThrow();
// Repeated calls give identical output — no hidden state between runs.
const a = layout([makeElement()], {}, PAGE_SETUP);
const b = layout([makeElement()], {}, PAGE_SETUP);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});
test('empty and missing element lists still return the pages[] shape', () => {
// Phase 6 grows this array; every caller must already handle the shape.
expect(layout([], {}, PAGE_SETUP)).toEqual({ pages: [[]] });
expect(layout(undefined, {}, PAGE_SETUP)).toEqual({ pages: [[]] });
});
test('static text resolves to its content, empty content to an empty string', () => {
const { pages } = layout(
[makeElement({ content: '' }), makeElement({ id: 'x', content: undefined })],
{}, PAGE_SETUP
);
expect(pages[0][0].text).toBe('');
expect(pages[0][1].text).toBe('');
});
test('bands are skipped in Phase 4 rather than printed as empty boxes', () => {
const { pages } = layout(
[makeElement(), { id: 'b1', type: 'a4-band', x: 0, y: 10, width: 210, height: 20 }],
{}, PAGE_SETUP
);
expect(pages[0]).toHaveLength(1);
expect(pages[0][0].el.id).toBe('a4-text-1');
});
});
describe('formatValue (copied from DBTextField for the pure path)', () => {
test('matches the matrix editor behaviour for each data type', () => {
expect(formatValue('', 'number')).toBe('');
expect(formatValue(null, 'text')).toBe('');
expect(formatValue(undefined, 'text')).toBe('');
expect(formatValue('42', 'number')).toBe('42');
expect(formatValue('abc', 'number')).toBe('');
expect(formatValue('not-a-date', 'date')).toBe('');
expect(formatValue('АА1234ВВ', 'text')).toBe('АА1234ВВ');
});
});
// ---------------------------------------------------------------------------
// PrintDocument — the actual CSS millimetre values
// ---------------------------------------------------------------------------
describe('PrintDocument', () => {
let container, root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (elements, pageSetup = PAGE_SETUP) => {
const { pages } = layout(elements, {}, pageSetup);
act(() => root.render(<PrintDocument pages={pages} pageSetup={pageSetup} />));
};
test('emits mm units for position and size — not px, not unitless', () => {
render([makeElement({ x: 20, y: 50, width: 170, height: 6 })]);
const el = container.querySelector('.a4-print-el');
// This is the criterion the ruler physically measures.
expect(el.style.left).toBe('20mm');
expect(el.style.top).toBe('50mm');
expect(el.style.width).toBe('170mm');
expect(el.style.height).toBe('6mm');
});
test('emits fractional mm without rounding', () => {
render([makeElement({ x: 12.7, y: 100.4, width: 85.5 })]);
const el = container.querySelector('.a4-print-el');
expect(el.style.left).toBe('12.7mm');
expect(el.style.top).toBe('100.4mm');
expect(el.style.width).toBe('85.5mm');
});
test('font size is emitted in pt with no conversion', () => {
// The element stores pt and CSS has real pt units, so the print path must
// not apply the pt->px*zoom conversion the screen path needs.
render([makeElement({ fontSize: 14 })]);
expect(container.querySelector('.a4-print-el').style.fontSize).toBe('14pt');
});
test('font, weight, style, alignment and colour reach the inline style', () => {
render([makeElement({
fontFamily: 'Calibri', bold: true, italic: true, align: 'right', color: '#ff0000'
})]);
const el = container.querySelector('.a4-print-el');
// Same stack string the screen uses — WYSIWYG by construction.
expect(el.style.fontFamily).toBe(getFontStack('Calibri'));
expect(el.style.fontWeight).toBe('700');
expect(el.style.fontStyle).toBe('italic');
expect(el.style.textAlign).toBe('right');
expect(el.style.color).toBe('rgb(255, 0, 0)');
});
test('renders the resolved text, Cyrillic intact', () => {
render([makeElement({ content: 'АА1234ВВ' })]);
expect(container.querySelector('.a4-print-el').textContent).toBe('АА1234ВВ');
});
test('page box is 210x297mm portrait and 297x210mm landscape', () => {
render([makeElement()]);
let page = container.querySelector('.a4-print-page');
expect(page.className).not.toContain('landscape');
render([makeElement()], { ...PAGE_SETUP, orientation: 'landscape' });
page = container.querySelector('.a4-print-page');
expect(page.className).toContain('a4-print-page--landscape');
});
test('uses .a4-print-page, NOT .a4-page — A4Canvas.css already owns that name', () => {
render([makeElement()]);
// A collision here would make the editor canvas and the printed page fight
// over background/shadow, since both stylesheets load together.
expect(container.querySelector('.a4-page')).toBeNull();
expect(container.querySelector('.a4-print-page')).not.toBeNull();
});
test('one page element per layout page, elements nested inside it', () => {
render([makeElement({ id: 'a' }), makeElement({ id: 'b' })]);
const pages = container.querySelectorAll('.a4-print-page');
expect(pages).toHaveLength(1);
expect(pages[0].querySelectorAll('.a4-print-el')).toHaveLength(2);
});
test('an element className is preserved alongside a4-print-el', () => {
render([makeElement({ className: 'a4-calibration-mark' })]);
const el = container.querySelector('.a4-print-el');
expect(el.classList.contains('a4-calibration-mark')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// The @media print visibility rule vs. the real mounted DOM
// ---------------------------------------------------------------------------
describe('print visibility rule', () => {
test('matches against the REAL mounted app tree, not a synthetic one', async () => {
// The app mounts into <div id="root">, a direct child of <body> (see
// public/index.html + src/index.jsx). This renders a real component into
// that real structure, then starts a real print, and checks the selector
// against what is actually in the document — the one check that catches
// "the print root ended up nested and the whole app printed instead".
const appRoot = document.createElement('div');
appRoot.id = 'root';
document.body.appendChild(appRoot);
const appReactRoot = createRoot(appRoot);
act(() => appReactRoot.render(<div className="report-editor">editor</div>));
window.print = vi.fn();
let finished;
await act(async () => {
finished = printA4Report(
{ pageSetup: PAGE_SETUP, elements: [makeElement()] }, {}
);
});
const hidden = [...document.body.children]
.filter(n => n.matches('body > *:not(.print-root)'));
const shown = [...document.body.children]
.filter(n => n.matches('.print-root'));
// Exactly one print root, and it is NOT among the hidden nodes.
expect(shown).toHaveLength(1);
expect(shown[0].querySelector('.a4-print-el')).not.toBeNull();
expect(hidden).not.toContain(shown[0]);
// The live editor tree IS hidden by the rule.
expect(hidden).toContain(appRoot);
await act(async () => {
window.dispatchEvent(new Event('afterprint'));
await finished;
});
act(() => appReactRoot.unmount());
appRoot.remove();
});
test('the print container is a DIRECT child of body and carries .print-root', () => {
// print.css hides everything with `body > *:not(.print-root)`, a CHILD
// combinator. If the class sat only on PrintDocument's inner div, the
// appended container would match the hiding rule and the print would be
// blank. This asserts the structural contract that rule depends on.
const appRoot = document.createElement('div');
appRoot.id = 'root';
document.body.appendChild(appRoot);
const container = document.createElement('div');
container.className = 'print-root';
document.body.appendChild(container);
expect(container.parentElement).toBe(document.body);
expect(container.matches('body > *:not(.print-root)')).toBe(false);
// ...and the app's own root, a sibling, IS hidden by it.
expect(appRoot.matches('body > *:not(.print-root)')).toBe(true);
appRoot.remove();
container.remove();
});
test('print.css really contains the rules the tests assert against', () => {
// The test runner stubs CSS imports to an empty module, so the selector tests
// above would still pass if print.css were deleted. Read the file itself so
// the shipped stylesheet is what is being verified.
const css = fs.readFileSync(
path.join(__dirname, '..', 'print', 'print.css'), 'utf8'
);
expect(css).toMatch(/@page\s*\{[^}]*size:\s*A4 portrait/);
expect(css).toMatch(/@page\s*\{[^}]*margin:\s*0/);
expect(css).toContain('body > *:not(.print-root)');
expect(css).toMatch(/\.a4-print-page\s*\{[^}]*width:\s*210mm/);
expect(css).toMatch(/\.a4-print-page\s*\{[^}]*height:\s*297mm/);
expect(css).toMatch(/\.a4-print-page--landscape\s*\{[^}]*width:\s*297mm/);
expect(css).toMatch(/\.a4-print-el\s*\{[^}]*position:\s*absolute/);
// Must NOT style .a4-page — A4Canvas.css owns that name for the editor.
expect(css).not.toMatch(/^\.a4-page[\s{,]/m);
});
});
// ---------------------------------------------------------------------------
// printReport() — mounting and, critically, teardown
// ---------------------------------------------------------------------------
describe('printA4Report', () => {
let printSpy;
const originalPrint = window.print;
beforeEach(() => {
printSpy = vi.fn();
window.print = printSpy;
});
afterEach(() => {
window.print = originalPrint;
document.getElementById('a4-print-landscape-page-rule')?.remove();
});
const report = () => ({
name: 'T', kind: 'a4', pageSetup: PAGE_SETUP,
elements: [makeElement({ content: 'АА1234ВВ' })]
});
/**
* Fire afterprint and let printA4Report's teardown complete. Cleanup is
* deferred through a setTimeout(0) (React forbids unmounting from inside the
* event that triggered it), so the timer has to be flushed before asserting.
*/
const finishPrint = async (pending) => {
await act(async () => {
window.dispatchEvent(new Event('afterprint'));
await pending;
});
};
test('mounts a .print-root on body, calls window.print, then removes it', async () => {
const before = document.body.innerHTML;
let finished;
await act(async () => { finished = printA4Report(report(), {}); });
// Mounted and rendered before print() was called.
const mounted = document.querySelector('.print-root');
expect(mounted).not.toBeNull();
expect(mounted.parentElement).toBe(document.body);
expect(mounted.querySelector('.a4-print-el').style.left).toBe('20mm');
expect(printSpy).toHaveBeenCalledTimes(1);
await finishPrint(finished);
// Teardown: the editor's DOM is exactly as it was.
expect(document.querySelector('.print-root')).toBeNull();
expect(document.body.innerHTML).toBe(before);
});
test('accepts the API shape (page_setup) as well as the editor shape', async () => {
let finished;
await act(async () => {
finished = printA4Report(
{ elements: [makeElement()], page_setup: { ...PAGE_SETUP, orientation: 'landscape' } },
{}
);
});
expect(document.querySelector('.a4-print-page--landscape')).not.toBeNull();
// @page size cannot come from a class, so a rule is injected for landscape.
expect(document.getElementById('a4-print-landscape-page-rule')).not.toBeNull();
await finishPrint(finished);
expect(document.getElementById('a4-print-landscape-page-rule')).toBeNull();
});
test('portrait does not inject the landscape @page rule', async () => {
let finished;
await act(async () => { finished = printA4Report(report(), {}); });
expect(document.getElementById('a4-print-landscape-page-rule')).toBeNull();
await finishPrint(finished);
});
test('repeated prints do not accumulate containers', async () => {
for (let i = 0; i < 3; i++) {
let finished;
await act(async () => { finished = printA4Report(report(), {}); });
await finishPrint(finished);
// Never more than one at a time, and none left behind.
expect(document.querySelectorAll('.print-root')).toHaveLength(0);
}
});
test('a duplicate afterprint does not double-unmount', async () => {
let finished;
await act(async () => { finished = printA4Report(report(), {}); });
await act(async () => {
window.dispatchEvent(new Event('afterprint'));
window.dispatchEvent(new Event('afterprint'));
await finished;
});
expect(document.querySelectorAll('.print-root')).toHaveLength(0);
});
test('a throwing window.print still tears the container down', async () => {
// Otherwise a blocked dialog leaves hidden markup stuck in <body>.
window.print = () => { throw new Error('blocked'); };
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await act(async () => { await printA4Report(report(), {}); });
expect(document.querySelectorAll('.print-root')).toHaveLength(0);
err.mockRestore();
});
});
// ---------------------------------------------------------------------------
// The calibration fixture — the numbers the user will measure
// ---------------------------------------------------------------------------
describe('calibration report (ruler-test fixture)', () => {
test('the measured line is at x=20, y=50, width=170, 1mm thick', () => {
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
const line = pages[0].find(p => p.el.id === 'a4-cal-line');
expect(line.x).toBe(20);
expect(line.y).toBe(50);
expect(line.el.width).toBe(170);
// Phase 5: a REAL a4-hline. Its thickness, not a height, is what the ruler
// measures — and it is a different render path from every boxed type.
expect(line.el.type).toBe('a4-hline');
expect(line.el.thickness).toBe(1);
});
test('the Cyrillic sample is at x=20, y=100 and survives layout', () => {
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
const text = pages[0].find(p => p.el.id === 'a4-cal-text');
expect(text.x).toBe(20);
expect(text.y).toBe(100);
expect(text.text).toBe('АА1234ВВ');
});
test('crosshairs are centred on the four specified corners', () => {
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
const at = (id) => pages[0].find(p => p.el.id === id);
// Each crosshair is a 10mm horizontal arm and a 10mm vertical arm whose
// centre lines cross at the named point.
[['tl', 10, 10], ['tr', 190, 10], ['bl', 10, 287], ['br', 190, 287]]
.forEach(([corner, cx, cy]) => {
const h = at(`a4-cal-${corner}-h`);
const v = at(`a4-cal-${corner}-v`);
expect(h.x).toBe(cx - 5);
expect(h.y).toBe(cy);
expect(h.el.type).toBe('a4-hline');
expect(h.el.width).toBe(10);
expect(v.x).toBe(cx);
expect(v.y).toBe(cy - 5);
expect(v.el.type).toBe('a4-vline');
expect(v.el.height).toBe(10);
});
});
test('every calibration element fits inside the A4 page box', () => {
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
pages[0].forEach(({ el, x, y }) => {
// Via getA4Bounds, not el.width/el.height: a line has only the dimension
// it runs along, so reading the raw props would compare against undefined
// and pass vacuously.
const b = getA4Bounds(el);
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(b.right).toBeLessThanOrEqual(210);
expect(b.bottom).toBeLessThanOrEqual(297);
});
});
test('the marks render as mm boxes in the print DOM', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const { pages } = layout(buildCalibrationReport().elements, {}, PAGE_SETUP);
act(() => root.render(<PrintDocument pages={pages} pageSetup={PAGE_SETUP} />));
const line = [...container.querySelectorAll('.a4-calibration-mark')]
.find(n => n.style.width === '170mm');
expect(line).toBeTruthy();
expect(line.style.left).toBe('20mm');
expect(line.style.top).toBe('50mm');
expect(line.style.height).toBe('1mm');
act(() => root.unmount());
container.remove();
});
});
@@ -0,0 +1,230 @@
/**
* Band instancing for the A4 designer — millimetre floats, PURE (no React).
*
* Ported from `utils/bandRenderer.js` (the matrix editor's engine), which is on
* the protected list and is NOT modified. Three differences, all forced by the
* change of unit:
*
* 1. Arithmetic is in mm floats, not integer character rows.
* 2. The inclusive `-1`/`+1` offsets are GONE. The matrix version tracks
* `band.y + band.height - 1` as a band's end because row 10 of a 3-row band
* starting at 8 is the LAST occupied row. Millimetres are continuous: a 3mm
* band at y=8 occupies [8, 11) and ends at 11. Keeping the -1 would shave a
* millimetre off every band and open a 1mm hole between consecutive bands.
* 3. `calculateTotalContentHeight`'s hardcoded 66 (rows) becomes the page's
* content height in mm, passed in by the caller.
*
* Everything else — including the recursive `parentBandId` nesting — ports
* structurally unchanged, because it was already unit-agnostic.
*/
/** Resolve a dotted path in an object (e.g. 'contact.phone'). */
function resolveNestedPath(obj, path) {
if (!path || !obj) return undefined;
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = current[part];
} else {
return undefined;
}
}
return current;
}
/**
* The array a band iterates over.
*
* @param {Object} reportData full report data
* @param {string} dataSource key/path of the array
* @param {Object} parentBandData current row of the enclosing band, if nested
* @returns {Array}
*/
export function getBandDataArray(reportData, dataSource, parentBandData = null) {
if (!dataSource) return [];
// Inside a parent band the source is resolved against that band's CURRENT row,
// which is what makes a subdetail iterate per-parent rather than globally.
if (parentBandData) {
const data = resolveNestedPath(parentBandData, dataSource);
return Array.isArray(data) ? data : [];
}
if (!reportData) return [];
const data = reportData[dataSource];
return Array.isArray(data) ? data : [];
}
/**
* Expand bands into positioned instances, one per data row.
*
* @param {Array} bands the `a4-band` elements
* @param {Object} reportData full report data
* @param {Array} allElements every element (children are looked up by id)
* @param {Object} parentBandData current row of the enclosing band, if nested
* @param {number} yOffset laid-out Y of the parent instance (nested only)
* @param {number} parentDesignY design Y of the parent band (nested only)
* @returns {Array<{bandId, instanceIndex, y, bandX, height, data, childElements,
* originalBandY, bandType}>} in flow order
*/
export function calculateBandInstances(
bands,
reportData,
allElements,
parentBandData = null,
yOffset = null,
parentDesignY = null
) {
const instances = [];
const currentLevelBands = bands.filter(band =>
parentBandData ? band.parentBandId === parentBandData.parentBandId : !band.parentBandId
);
const sortedBands = [...currentLevelBands].sort((a, b) => a.y - b.y);
// Where the previous band ended, in DESIGN space and in LAID-OUT space. The
// difference between the two is the accumulated growth from data iteration.
let previousBandDesignEnd = null;
let previousBandPreviewEnd = null;
let currentY = 0;
for (const band of sortedBands) {
if (previousBandDesignEnd === null) {
if (yOffset !== null && parentDesignY !== null) {
// Nested: keep the designed offset from the parent's top.
currentY = yOffset + (band.y - parentDesignY);
} else {
currentY = band.y;
}
} else {
// Preserve the designed gap between bands, after the previous band has
// grown by however many rows it rendered.
const designGap = band.y - previousBandDesignEnd;
currentY = previousBandPreviewEnd + designGap;
}
const dataArray = getBandDataArray(reportData, band.dataSource, parentBandData);
const childElements = allElements.filter(
el => band.children && band.children.includes(el.id)
);
const nestedBands = bands.filter(b => b.parentBandId === band.id);
const emit = (data, instanceIndex) => {
instances.push({
bandId: band.id,
instanceIndex,
y: currentY,
// The band's left edge, so layout() can turn a child's band-relative x
// into a page x. Children render inside the band div on the canvas, so
// CSS supplies this offset there for free.
bandX: band.x || 0,
height: band.height,
data,
childElements,
originalBandY: band.y,
bandType: band.bandType
});
if (nestedBands.length > 0) {
const nestedInstances = calculateBandInstances(
nestedBands,
reportData,
allElements,
// A static band (no data) passes no row down, so a nested band under it
// resolves its own source against reportData instead.
data === null ? null : { ...data, parentBandId: band.id },
currentY,
band.y
);
instances.push(...nestedInstances);
currentY = nestedInstances.reduce(
(max, ni) => Math.max(max, ni.y + ni.height),
currentY + band.height
);
} else {
currentY += band.height;
}
};
if (dataArray.length === 0) {
// No data — render once with null. This is how header/footer/summary bands
// and any unbound band get exactly one instance.
emit(null, 0);
} else {
dataArray.forEach((item, index) => emit(item, index));
}
previousBandDesignEnd = band.y + band.height;
previousBandPreviewEnd = currentY;
}
return instances;
}
/**
* Total laid-out height in mm, before pagination splits it into pages.
*
* @param {number} minHeightMm the page's content height — the matrix version
* hardcoded 66 rows here, which is meaningless in mm.
*/
export function calculateTotalContentHeight(bands, reportData, allElements, minHeightMm) {
if (!bands || bands.length === 0) return minHeightMm;
const instances = calculateBandInstances(bands, reportData, allElements);
if (instances.length === 0) return minHeightMm;
return instances.reduce((max, i) => Math.max(max, i.y + i.height), minHeightMm);
}
/**
* Arrays available as a band's dataSource — drives the inspector dropdown.
*
* With a parent band, the candidates come from a SAMPLE ROW of the parent's
* array rather than from the top level, because a nested band's source is
* resolved against the parent's current row.
*/
export function getAvailableArrays(reportData, parentBand = null) {
const arrays = [];
if (parentBand && parentBand.dataSource) {
const parentDataArray = reportData?.[parentBand.dataSource];
if (Array.isArray(parentDataArray) && parentDataArray.length > 0) {
findArraysInObject(parentDataArray[0], arrays);
return arrays;
}
return [];
}
if (reportData && typeof reportData === 'object') {
for (const key of Object.keys(reportData)) {
if (Array.isArray(reportData[key])) {
arrays.push({ key, path: key });
}
}
}
return arrays;
}
/** Collect arrays inside an object, recursing through plain objects only. */
function findArraysInObject(obj, arrays, prefix = '') {
if (!obj || typeof obj !== 'object') return;
for (const key of Object.keys(obj)) {
const value = obj[key];
const path = prefix ? `${prefix}.${key}` : key;
if (Array.isArray(value)) {
arrays.push({ key, path });
// Deliberately no recursion INTO arrays — only immediate arrays are
// usable as a data source.
} else if (typeof value === 'object' && value !== null) {
findArraysInObject(value, arrays, path);
}
}
}
@@ -0,0 +1,234 @@
/* A4 designer element styles — CSS borders and real fonts, no box characters. */
.a4-text-field {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: flex-start;
overflow: hidden;
cursor: move;
outline: 1px dashed transparent;
line-height: 1.2;
user-select: none;
}
.a4-text-field:hover {
outline-color: #b9c2f0;
}
.a4-text-field.selected {
outline: 1px solid #667eea;
}
.a4-text-field.dragging {
opacity: 0.75;
}
.a4-text-field-content {
width: 100%;
white-space: pre-wrap;
word-break: break-word;
}
.a4-text-field-input {
width: 100%;
border: 1px solid #667eea;
padding: 0;
margin: 0;
background: #fff;
font: inherit;
color: inherit;
text-align: inherit;
box-sizing: border-box;
}
.a4-text-field-input:focus {
outline: none;
}
/* Data-bound and expression fields share the text box's behaviour. */
.a4-db-text-field,
.a4-expr-field {
position: absolute;
box-sizing: border-box;
display: flex;
align-items: flex-start;
overflow: hidden;
cursor: move;
outline: 1px dashed transparent;
line-height: 1.2;
user-select: none;
}
.a4-db-text-field:hover,
.a4-expr-field:hover {
outline-color: #b9c2f0;
}
.a4-db-text-field.selected,
.a4-expr-field.selected {
outline: 1px solid #667eea;
}
.a4-db-text-field.dragging,
.a4-expr-field.dragging {
opacity: 0.75;
}
/* An unbound field is invisible on a blank page otherwise — this is a DESIGN
affordance only; nothing in the print path reads this class. */
.a4-db-text-field.unresolved,
.a4-expr-field.unresolved {
outline: 1px dashed #e08a3c;
background: rgba(224, 138, 60, 0.06);
}
.a4-field-content {
width: 100%;
white-space: pre-wrap;
word-break: break-word;
}
/* Rectangle — a real CSS border, sized in mm. */
.a4-rect {
position: absolute;
box-sizing: border-box;
cursor: move;
}
.a4-rect.selected {
outline: 1px solid #667eea;
outline-offset: 1px;
}
.a4-rect.dragging {
opacity: 0.75;
}
/* Lines. The visible box IS the line, so selection must never be drawn as a
border (that would change the apparent thickness) — an offset outline keeps
the measured geometry honest. */
.a4-hline,
.a4-vline {
position: absolute;
box-sizing: border-box;
cursor: move;
}
.a4-hline.selected,
.a4-vline.selected {
outline: 1px solid #667eea;
outline-offset: 2px;
}
.a4-hline.dragging,
.a4-vline.dragging {
opacity: 0.75;
}
/* A 0.3mm line is about 1px on screen and effectively unclickable. This
invisible pseudo-element widens the GRAB area to ~9px across without
touching the painted line's millimetres. */
.a4-hline::before,
.a4-vline::before {
content: '';
position: absolute;
background: transparent;
}
.a4-hline::before {
left: 0;
right: 0;
top: -4px;
bottom: -4px;
}
.a4-vline::before {
top: 0;
bottom: 0;
left: -4px;
right: -4px;
}
/* Calibration marks (the ruler-test fixture). Now that a4-hline/a4-vline
exist, the calibration report uses REAL lines; this class survives only to
mark them visually distinct in the editor. Same rule in print.css. */
.a4-calibration-mark {
outline: none !important;
}
/* Resize handles */
.a4-resize-handle {
position: absolute;
width: 8px;
height: 8px;
background: #fff;
border: 1px solid #667eea;
border-radius: 1px;
z-index: 20;
}
.a4-resize-handle:hover {
background: #667eea;
}
/* Transitions are disabled globally while useElementDrag is active */
body.dragging-active .a4-text-field,
body.dragging-active .a4-db-text-field,
body.dragging-active .a4-expr-field,
body.dragging-active .a4-rect,
body.dragging-active .a4-hline,
body.dragging-active .a4-vline {
transition: none !important;
}
/* Band container — a DESIGN-TIME affordance only. layout() never emits the band
itself, so none of this reaches paper; only its children print. */
/* A horizontal STRIP, not a box: top/bottom rules only, as in the matrix
editor. A band spans the content width and moves only up and down, so side
borders would suggest a left/right edge the user can take hold of. */
.a4-band {
position: absolute;
box-sizing: border-box;
border: 1px dashed #7a8bb8;
border-left: none;
border-right: none;
background: rgba(122, 139, 184, 0.05);
cursor: ns-resize;
}
.a4-band.selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.a4-band.dragging {
opacity: 0.75;
}
/* Colour-code the band types so a stack of them is readable at a glance. */
.a4-band--header { border-color: #3c8ae0; }
.a4-band--footer { border-color: #3c8ae0; }
.a4-band--detail { border-color: #4caf7d; }
.a4-band--subdetail { border-color: #9c8ae0; }
.a4-band--summary { border-color: #e0913c; }
.a4-band-caption {
position: absolute;
top: 0;
left: 0;
padding: 0 4px;
font-family: system-ui, sans-serif;
font-size: 9px;
line-height: 12px;
color: #40506f;
background: rgba(255, 255, 255, 0.85);
white-space: nowrap;
pointer-events: none;
user-select: none;
z-index: 2;
}
body.dragging-active .a4-band {
transition: none !important;
}
@@ -0,0 +1,127 @@
/**
* The calibration report — the fixture for the plan's §8 "ruler test".
*
* This is the acceptance test for millimetre correctness, and it runs on PAPER,
* with a steel rule. Everything in the A4 designer is built on the assumption
* that a stored millimetre prints as a physical millimetre; this report is what
* proves it.
*
* PHASE 5 CHANGE — the marks are now REAL LINES.
* Phase 4 had only one element type, `a4-text`, so each "line" was a text box
* with empty content whose painted background stood in for the line. That
* measured the shared coordinate pipeline, but it could not measure the hline
* render path, because there was none. Now that `a4-hline` and `a4-vline`
* exist, the fixture uses them: the ruler test now exercises exactly the
* element type a real report will use for a rule, including its own
* `thickness` -> CSS mm height conversion, which is a separate code path from
* the `height` every other type uses.
*
* The geometry is unchanged from Phase 4, so a sheet printed before and after
* this change must measure identically. If it does not, the hline render path
* is what differs — which is precisely what this change makes measurable.
*
* The marks are 1mm thick, so "measure the line's top edge" is unambiguous.
*/
const MARK_THICKNESS_MM = 1;
const hline = (id, x, y, width) => ({
id,
type: 'a4-hline',
x,
y,
width,
thickness: MARK_THICKNESS_MM,
color: '#000000',
className: 'a4-calibration-mark'
});
const vline = (id, x, y, height) => ({
id,
type: 'a4-vline',
x,
y,
height,
thickness: MARK_THICKNESS_MM,
color: '#000000',
className: 'a4-calibration-mark'
});
/** Crosshair at (cx, cy): a 10mm horizontal and a 10mm vertical arm, 1mm thick. */
const crosshair = (id, cx, cy) => [
hline(`${id}-h`, cx - 5, cy, 10),
vline(`${id}-v`, cx, cy - 5, 10)
];
const label = (id, y, content, bold = false) => ({
id,
type: 'a4-text',
x: 20,
y,
width: 170,
height: 6,
content,
fontFamily: 'Arial',
fontSize: 9,
bold,
italic: false,
align: 'left',
color: '#000000'
});
export const CALIBRATION_PAGE_SETUP = {
page: 'A4',
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 20 },
defaultFont: 'Arial',
defaultFontSize: 10
};
export function buildCalibrationReport() {
return {
name: 'A4 calibration (ruler test)',
kind: 'a4',
pageSetup: { ...CALIBRATION_PAGE_SETUP, margins: { ...CALIBRATION_PAGE_SETUP.margins } },
apiEndpoint: '',
elements: [
// THE MEASUREMENT. Left end at x=20mm, top edge at y=50mm, length 170mm.
hline('a4-cal-line', 20, 50, 170),
// Cyrillic, to prove the font really renders it (and is not a fallback
// box). 14pt so it is legible enough to compare against Word on paper.
{
id: 'a4-cal-text',
type: 'a4-text',
x: 20,
y: 100,
width: 80,
height: 8,
content: 'АА1234ВВ',
fontFamily: 'Arial',
fontSize: 14,
bold: false,
italic: false,
align: 'left',
color: '#000000'
},
// Corner crosshairs — a consistent offset in all four proves a page-setup
// or unprintable-area problem; an offset in only one or two proves skew.
...crosshair('a4-cal-tl', 10, 10),
...crosshair('a4-cal-tr', 190, 10),
...crosshair('a4-cal-bl', 10, 287),
...crosshair('a4-cal-br', 190, 287),
// Labels so the printed sheet documents its own expected values — the
// person with the ruler should not have to read this file.
label('a4-cal-label-line', 40,
'Line below: left edge = 20mm, top edge = 50mm, length = 170mm'),
label('a4-cal-label-text', 92,
'Cyrillic below at x=20mm, y=100mm. Crosshair centres: 10/10, 190/10, 10/287, 190/287'),
label('a4-cal-label-scale', 120,
'Print at 100% / Actual size, with Headers and footers UNTICKED.', true)
]
};
}
export default buildCalibrationReport;
@@ -0,0 +1,104 @@
import React from 'react';
import A4ResizeHandles, { VERTICAL_HANDLES } from './A4ResizeHandles';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { mmToPx } from '../units';
import '../a4Elements.css';
/**
* Band container for the A4 designer.
*
* DESIGN-TIME ONLY. The dashed border and the caption are affordances for the
* editor; `layout()` never emits the band itself, so nothing here reaches paper.
* What prints is the band's CHILDREN, once per data row.
*
* Two deliberate constraints:
*
* - X and WIDTH are not user-set. A band spans the page's content width, which
* is derived from page_setup's margins. Offering a width control would let a
* user set one that the next margin change silently contradicts.
* - Only the VERTICAL resize handles are offered, for the same reason — a west
* or east handle would write a width the layout engine recomputes anyway.
*
* Children are rendered INSIDE this element, so their stored `y` is relative to
* the band (matching the matrix editor) and CSS positioning does the offset for
* free rather than every child needing to know its parent's position.
*/
function A4Band({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode,
children
}) {
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
// A mousedown that started on a CHILD must not also start a band drag, or
// moving one field inside a band would drag the whole band with it.
if (e.target !== e.currentTarget) return;
onSelect();
handleDragMouseDown(e, element.id);
};
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${mmToPx(element.width, zoom)}px`,
height: `${mmToPx(element.height, zoom)}px`
};
const className = [
'a4-band',
`a4-band--${element.bandType || 'detail'}`,
isSelected ? 'selected' : '',
isDragging ? 'dragging' : ''
].filter(Boolean).join(' ');
const caption = [
element.caption,
element.bandType || 'detail',
element.dataSource ? `→ ${element.dataSource}` : null
].filter(Boolean).join(' · ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
data-band-type={element.bandType || 'detail'}
>
<span className="a4-band-caption">{caption}</span>
{children}
{isSingleSelection && (
<A4ResizeHandles
element={element}
onResize={onUpdate}
zoom={zoom}
handles={VERTICAL_HANDLES}
/>
)}
</div>
);
}
export default A4Band;
@@ -0,0 +1,103 @@
import React from 'react';
import A4ResizeHandles from './A4ResizeHandles';
import { useReportData } from '../../DataContext';
import { resolveDBTextValue } from '../../utils/dataResolver';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { formatValue } from '../formatValue';
import { mmToPx, ptToPx } from '../units';
import { getFontStack } from '../fonts';
import '../a4Elements.css';
/**
* Data-bound field for the A4 designer.
*
* Resolution logic follows the matrix `DBTextField.jsx`: design mode shows the
* binding as a `{object.field}` placeholder so an unbound field is visible on a
* blank canvas, preview mode shows the resolved and formatted value. The
* styling is mm-native instead of charWidth/charHeight, and it uses the shared
* `a4/formatValue.js` rather than a third local copy of that function.
*
* Bands are Phase 6, so there is no parentBandId here yet: `resolveDBTextValue`
* is called with the top-level reportData only.
*/
function A4DBTextField({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode,
previewMode = false
}) {
const { reportData } = useReportData();
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const displayContent = previewMode
? formatValue(
resolveDBTextValue(reportData, element.objectKey, element.fieldPath),
element.dataType || 'general'
)
: `{${element.objectKey || '?'}.${element.fieldPath || '?'}}`;
const isUnresolved = !element.objectKey || !element.fieldPath ||
(previewMode && !displayContent);
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${mmToPx(element.width, zoom)}px`,
height: `${mmToPx(element.height, zoom)}px`,
fontFamily: getFontStack(element.fontFamily),
fontSize: `${ptToPx(element.fontSize, zoom)}px`,
fontWeight: element.bold ? 700 : 400,
fontStyle: element.italic ? 'italic' : 'normal',
textAlign: element.align || 'left',
color: element.color || '#000000'
};
const className = [
'a4-db-text-field',
isSelected ? 'selected' : '',
isDragging ? 'dragging' : '',
isUnresolved ? 'unresolved' : ''
].filter(Boolean).join(' ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
<span className="a4-field-content">{displayContent || '(No Data)'}</span>
{isSingleSelection && (
<A4ResizeHandles element={element} onResize={onUpdate} zoom={zoom} />
)}
</div>
);
}
export default A4DBTextField;
@@ -0,0 +1,102 @@
import React from 'react';
import A4ResizeHandles from './A4ResizeHandles';
import { useReportData } from '../../DataContext';
import { evaluateExpression } from '../../utils/expressionEvaluator';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { formatValue } from '../formatValue';
import { makeFieldResolver } from '../layout';
import { mmToPx, ptToPx } from '../units';
import { getFontStack } from '../fonts';
import '../a4Elements.css';
/**
* Computed-expression field for the A4 designer.
*
* Same shape as the matrix `ExpressionField.jsx`: design mode shows the raw
* expression source, preview mode shows the evaluated and formatted result.
*
* The `[object.field]` resolver is IMPORTED from layout.js rather than copied.
* That matters beyond tidiness — the screen and the printed page must agree on
* what a reference resolves to, and one function is the only way to guarantee
* that. It is safe to import because layout.js is pure: no React, no DOM.
*/
function A4ExprField({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode,
previewMode = false
}) {
const { reportData } = useReportData();
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const displayContent = previewMode
? formatValue(
evaluateExpression(element.expression, makeFieldResolver(reportData)),
element.dataType || 'general'
)
: (element.expression || '(expr)');
const isUnresolved = !element.expression;
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${mmToPx(element.width, zoom)}px`,
height: `${mmToPx(element.height, zoom)}px`,
fontFamily: getFontStack(element.fontFamily),
fontSize: `${ptToPx(element.fontSize, zoom)}px`,
fontWeight: element.bold ? 700 : 400,
fontStyle: element.italic ? 'italic' : 'normal',
textAlign: element.align || 'left',
color: element.color || '#000000'
};
const className = [
'a4-expr-field',
isSelected ? 'selected' : '',
isDragging ? 'dragging' : '',
isUnresolved ? 'unresolved' : ''
].filter(Boolean).join(' ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
<span className="a4-field-content">{displayContent || '(No Expr)'}</span>
{isSingleSelection && (
<A4ResizeHandles element={element} onResize={onUpdate} zoom={zoom} />
)}
</div>
);
}
export default A4ExprField;
@@ -0,0 +1,90 @@
import React from 'react';
import A4ResizeHandles, { HORIZONTAL_HANDLES } from './A4ResizeHandles';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { mmToPx } from '../units';
import '../a4Elements.css';
/**
* Horizontal rule for the A4 designer.
*
* A filled box `width` mm long and `thickness` mm tall — NOT a box-drawing
* character, and with NO crossroad/junction detection. Junctions are a
* character-grid concept: on a grid two lines meeting must agree on which glyph
* occupies the shared cell. Millimetre lines simply overlap, and the browser
* paints both.
*
* Background rather than border-top, so the painted height is exactly the
* stored thickness with no box-sizing interaction to reason about.
*
* Hit target: a 0.3mm line is ~1px on screen, which is unclickable. The CSS
* gives the element a transparent padded hit area via a pseudo-element, so the
* visible line stays honest while the grab zone is usable.
*/
function A4HLine({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode
}) {
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${mmToPx(element.width, zoom)}px`,
// Floored at 1px so a hairline does not vanish on screen when zoomed out.
// The printed thickness comes from PrintDocument's true mm, not from here.
height: `${Math.max(1, mmToPx(element.thickness || 0, zoom))}px`,
background: element.color || '#000000'
};
const className = [
'a4-hline',
isSelected ? 'selected' : '',
isDragging ? 'dragging' : ''
].filter(Boolean).join(' ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{isSingleSelection && (
<A4ResizeHandles
element={element}
onResize={onUpdate}
zoom={zoom}
handles={HORIZONTAL_HANDLES}
/>
)}
</div>
);
}
export default A4HLine;
@@ -0,0 +1,89 @@
import React from 'react';
import A4ResizeHandles from './A4ResizeHandles';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { mmToPx } from '../units';
import '../a4Elements.css';
/**
* Rectangle / frame for the A4 designer.
*
* A REAL CSS border, not the matrix editor's box-drawing characters. Those
* exist because a dot-matrix printer draws with a character grid; on A4 a
* border is a border, and its thickness is a millimetre float.
*
* Border width is converted through the zoom like every other mm value, but
* is floored at 1px on screen: at 50% zoom a 0.3mm border is 0.57px, which
* some browsers round away to nothing, making the rect look deleted. Print is
* unaffected — PrintDocument emits the true mm.
*
* `fillColor: null` means no fill (the default), so a rect can frame content
* without hiding it. Only an explicit colour paints a background.
*/
function A4Rect({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode
}) {
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const borderPx = element.borderWidth > 0
? Math.max(1, mmToPx(element.borderWidth, zoom))
: 0;
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${mmToPx(element.width, zoom)}px`,
height: `${mmToPx(element.height, zoom)}px`,
border: borderPx > 0
? `${borderPx}px solid ${element.borderColor || '#000000'}`
: 'none',
background: element.fillColor || 'transparent'
};
const className = [
'a4-rect',
isSelected ? 'selected' : '',
isDragging ? 'dragging' : ''
].filter(Boolean).join(' ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{isSingleSelection && (
<A4ResizeHandles element={element} onResize={onUpdate} zoom={zoom} />
)}
</div>
);
}
export default A4Rect;
@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { mmToPx, pxToMm, roundMm } from '../units';
import '../a4Elements.css';
/**
* Resize in millimetres.
*
* Structural reference: the matrix `ResizeHandles.jsx` — but deltas convert to mm
* floats via pxToMm(delta, zoom) + roundMm. There is NO rounding to integer cells.
*
* The handle SET is a prop because lines have only one resizable dimension: an
* hline is `width` + `thickness`, with no `height` at all. Offering it a south
* handle would write a `height` the renderer never reads — a silent no-op the
* user would read as a broken control. So an hline gets ['w','e'] and a vline
* ['n','s'], and the code below only ever touches the axis its handle names.
*/
const MIN_WIDTH_MM = 5;
const MIN_HEIGHT_MM = 3;
export const ALL_HANDLES = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'];
export const HORIZONTAL_HANDLES = ['w', 'e'];
export const VERTICAL_HANDLES = ['n', 's'];
const CURSORS = {
nw: 'nw-resize',
n: 'n-resize',
ne: 'ne-resize',
w: 'w-resize',
e: 'e-resize',
sw: 'sw-resize',
s: 's-resize',
se: 'se-resize'
};
function A4ResizeHandles({ element, onResize, zoom, handles = ALL_HANDLES }) {
const [dragData, setDragData] = useState(null);
const getHandlePosition = (handle) => {
// Lines carry only the dimension they run along; the other extent is their
// thickness. Same substitution geometry.js makes, for the same reason.
const width = mmToPx(
element.type === 'a4-vline' ? (element.thickness || 0) : element.width, zoom
);
const height = mmToPx(
element.type === 'a4-hline' ? (element.thickness || 0) : element.height, zoom
);
const positions = {
nw: { left: -4, top: -4 },
n: { left: width / 2 - 4, top: -4 },
ne: { left: width - 4, top: -4 },
w: { left: -4, top: height / 2 - 4 },
e: { left: width - 4, top: height / 2 - 4 },
sw: { left: -4, top: height - 4 },
s: { left: width / 2 - 4, top: height - 4 },
se: { left: width - 4, top: height - 4 }
};
return positions[handle];
};
const handleMouseDown = (e, handle) => {
e.stopPropagation();
setDragData({
handle,
startX: e.clientX,
startY: e.clientY,
initialElement: { ...element }
});
};
useEffect(() => {
if (!dragData) return;
const handleMouseMove = (e) => {
const deltaMmX = pxToMm(e.clientX - dragData.startX, zoom);
const deltaMmY = pxToMm(e.clientY - dragData.startY, zoom);
const initial = dragData.initialElement;
const { handle } = dragData;
const updates = {};
// Horizontal: west handles move the origin, east handles only grow width
if (handle.includes('w')) {
const newWidth = Math.max(MIN_WIDTH_MM, initial.width - deltaMmX);
updates.x = roundMm(initial.x + (initial.width - newWidth));
updates.width = roundMm(newWidth);
} else if (handle.includes('e')) {
updates.width = roundMm(Math.max(MIN_WIDTH_MM, initial.width + deltaMmX));
}
// Vertical: north handles move the origin, south handles only grow height
if (handle.includes('n')) {
const newHeight = Math.max(MIN_HEIGHT_MM, initial.height - deltaMmY);
updates.y = roundMm(initial.y + (initial.height - newHeight));
updates.height = roundMm(newHeight);
} else if (handle.includes('s')) {
updates.height = roundMm(Math.max(MIN_HEIGHT_MM, initial.height + deltaMmY));
}
if (Object.keys(updates).length > 0) {
onResize(updates);
}
};
const handleMouseUp = () => setDragData(null);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [dragData, onResize, zoom]);
return (
<>
{handles.map(handle => (
<div
key={handle}
className="a4-resize-handle"
style={{ ...getHandlePosition(handle), cursor: CURSORS[handle] }}
onMouseDown={(e) => handleMouseDown(e, handle)}
/>
))}
</>
);
}
export default A4ResizeHandles;
@@ -0,0 +1,150 @@
import React, { useState, useRef, useEffect } from 'react';
import A4ResizeHandles from './A4ResizeHandles';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { mmToPx, ptToPx } from '../units';
import { getFontStack } from '../fonts';
import '../a4Elements.css';
/**
* Static text element for the A4 designer.
*
* Position/size are mm floats converted to px at render time; font size is pt
* rendered as pt * (96/72) * zoom px. Nothing here writes a pixel value back.
*/
function A4TextField({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode
}) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(element.content);
const inputRef = useRef(null);
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete: () => {
if (!isEditing) onDelete();
},
toolMode
});
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
if (!isSelected) {
setIsEditing(false);
}
}, [isSelected]);
const handleDoubleClick = (e) => {
if (toolMode === 'select') {
e.stopPropagation();
if (!isEditing) {
setIsEditing(true);
setEditContent(element.content);
}
}
};
const handleBlur = () => {
if (isEditing) {
setIsEditing(false);
if (editContent !== element.content) {
onUpdate({ content: editContent });
}
}
};
const handleKeyDown = (e) => {
// Stop the canvas-level shortcut handler seeing keys typed into this input
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
handleBlur();
} else if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(element.content);
}
};
const handleMouseDown = (e) => {
if (isEditing) return;
onSelect();
handleDragMouseDown(e, element.id);
};
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${mmToPx(element.width, zoom)}px`,
height: `${mmToPx(element.height, zoom)}px`,
fontFamily: getFontStack(element.fontFamily),
fontSize: `${ptToPx(element.fontSize, zoom)}px`,
fontWeight: element.bold ? 700 : 400,
fontStyle: element.italic ? 'italic' : 'normal',
textAlign: element.align || 'left',
color: element.color || '#000000'
};
const className = [
'a4-text-field',
element.className || '',
isSelected ? 'selected' : '',
isDragging ? 'dragging' : ''
].filter(Boolean).join(' ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
className="a4-text-field-input"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<span className="a4-text-field-content">{element.content}</span>
)}
{isSingleSelection && !isEditing && (
<A4ResizeHandles
element={element}
onResize={onUpdate}
zoom={zoom}
/>
)}
</div>
);
}
export default A4TextField;
@@ -0,0 +1,76 @@
import React from 'react';
import A4ResizeHandles, { VERTICAL_HANDLES } from './A4ResizeHandles';
import { useElementDrag } from '../../hooks/useElementDrag';
import { useElementSelection } from '../../hooks/useElementSelection';
import { mmToPx } from '../units';
import '../a4Elements.css';
/**
* Vertical rule for the A4 designer. The transpose of A4HLine — see its header
* for why this is a filled box with no junction detection.
*/
function A4VLine({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
zoom,
toolMode
}) {
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const style = {
left: `${mmToPx(element.x, zoom)}px`,
top: `${mmToPx(element.y, zoom)}px`,
width: `${Math.max(1, mmToPx(element.thickness || 0, zoom))}px`,
height: `${mmToPx(element.height, zoom)}px`,
background: element.color || '#000000'
};
const className = [
'a4-vline',
isSelected ? 'selected' : '',
isDragging ? 'dragging' : ''
].filter(Boolean).join(' ');
return (
<div
className={className}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{isSingleSelection && (
<A4ResizeHandles
element={element}
onResize={onUpdate}
zoom={zoom}
handles={VERTICAL_HANDLES}
/>
)}
</div>
);
}
export default A4VLine;
@@ -0,0 +1,24 @@
/**
* Fonts offered by the A4 designer.
*
* Only Windows-bundled families, all of which cover Cyrillic. Chrome prints with
* the fonts installed on the station, so no embedding or substitution is needed.
*
* The SAME stack string is used on screen and (Phase 4) in print CSS, so WYSIWYG
* holds by construction — one font stack, one rendering engine.
*/
export const FONT_STACKS = {
'Arial': 'Arial, Helvetica, sans-serif',
'Times New Roman': '"Times New Roman", Times, serif',
'Courier New': '"Courier New", Courier, monospace',
'Calibri': 'Calibri, Arial, sans-serif',
'Segoe UI': '"Segoe UI", Tahoma, Arial, sans-serif',
'Tahoma': 'Tahoma, Verdana, Arial, sans-serif',
'Verdana': 'Verdana, Tahoma, Arial, sans-serif'
};
export const FONT_FAMILIES = Object.keys(FONT_STACKS);
/** CSS font-family stack for a stored family name, falling back to Arial. */
export const getFontStack = (family) => FONT_STACKS[family] || FONT_STACKS['Arial'];
@@ -0,0 +1,34 @@
/**
* Value formatting for the A4 designer.
*
* Copied verbatim from `DBTextField.jsx:11-33` — where the same function is
* already duplicated byte-for-byte in `ExpressionField.jsx`. Lifting it here
* consolidates an existing duplicate rather than creating a new one, and gives
* the pure `layout()` path a formatter that does not import React.
*/
export function formatValue(raw, dataType) {
if (raw === '' || raw === null || raw === undefined) return '';
switch (dataType) {
case 'number': {
const n = Number(raw);
return isNaN(n) ? '' : String(n);
}
case 'date': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
}
case 'time': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
case 'datetime': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
default:
return String(raw);
}
}
export default formatValue;
@@ -0,0 +1,61 @@
/**
* Geometry for the A4 designer — millimetres, EXCLUSIVE bounds.
*
* NOTE the deliberate difference from the matrix editor: `models/Element.jsx`
* uses inclusive bounds (`x + width - 1`), which is right for character cells
* and wrong for millimetres. A 40mm-wide box at x=10 occupies [10, 50) and its
* right edge — what Phase 3's alignment guides align to — is 50, not 49.
*/
/**
* Exclusive bounds in mm: { left, top, right, bottom, centerX, middleY }.
*
* Lines carry only the dimension they run along (`width` for hline, `height`
* for vline); their other extent is `thickness`. Without that substitution an
* hline would have bottom === top, so it would be a zero-height box: invisible
* to the rubber-band hit test and unalignable by Phase 3's guides.
*/
export function getA4Bounds(el) {
const isHLine = el.type === 'a4-hline';
const isVLine = el.type === 'a4-vline';
const thickness = el.thickness || 0;
const spanX = isVLine ? thickness : (el.width || 0);
const spanY = isHLine ? thickness : (el.height || 0);
const left = el.x;
const top = el.y;
const right = el.x + spanX;
const bottom = el.y + spanY;
return {
left,
top,
right,
bottom,
centerX: (left + right) / 2,
middleY: (top + bottom) / 2
};
}
/**
* Rubber-band hit test. `rect` is { minX, maxX, minY, maxY } in mm.
* Touching edges count as intersecting, matching the exclusive convention.
*/
export function a4IntersectsRect(el, rect) {
const b = getA4Bounds(el);
return !(
b.right < rect.minX ||
b.left > rect.maxX ||
b.bottom < rect.minY ||
b.top > rect.maxY
);
}
/** Clamp an element's top-left so its box stays inside the page. */
export function clampToPage(x, y, width, height, pageW, pageH) {
return {
x: Math.max(0, Math.min(pageW - width, x)),
y: Math.max(0, Math.min(pageH - height, y))
};
}
@@ -0,0 +1,174 @@
/**
* Smart alignment guides for the A4 designer — pure functions, no React, no DOM.
*
* The feature the customer actually asked for: "helper lines to show if a field
* matches other fields' positions". Two halves:
*
* buildGuideCandidates() — every position worth aligning to, built ONCE per
* drag and held in a ref by A4Canvas.
* resolveSnap() — per mousemove: the nudged x/y plus the lines to draw.
*
* Both work in millimetres, on the EXCLUSIVE bounds from geometry.js. A 40mm box
* at x=10 has its right edge at 50, so aligning "right edge to right edge" means
* comparing 50 to 50 — with inclusive bounds the two boxes would sit 0.1mm apart
* and the guide would lie.
*/
import { PX_PER_MM, getPageSize } from './units';
import { getA4Bounds } from './geometry';
/**
* Snap threshold in SCREEN pixels, deliberately not millimetres.
*
* resolveSnap divides this by (PX_PER_MM * zoom), so the magnet's grab distance
* is a constant number of pixels under the cursor at every zoom level — which is
* what a user means by "it feels the same". A fixed mm threshold would instead
* feel sticky when zoomed in (6mm of screen travel at 150%) and useless when
* zoomed out. At 100% this works out to ~1.6mm.
*/
export const SNAP_PX = 6;
/** Threshold in mm for the current zoom. Exported so tests can assert the
* inverse-zoom relationship directly rather than inferring it from snapping. */
export const snapToleranceMm = (zoom) => SNAP_PX / (PX_PER_MM * zoom);
/**
* Every position worth aligning to, split by axis.
*
* `V` holds vertical lines (constant x), `H` horizontal lines (constant y) —
* named for the line drawn, not the coordinate compared, because that is what
* the overlay renders.
*
* Bands are skipped as sources: a band always spans the full content width, so
* its left/right edges ARE the page margins, which are already emitted below.
* Emitting them again would produce duplicate candidates at identical positions
* and a doubled-up guide line. Band CHILDREN still generate candidates normally
* — they are ordinary elements that happen to live inside a band.
*
* @param {Array} elements all elements in the report
* @param {Array} excludeIds ids being dragged (they cannot align to themselves)
* @param {Object} pageSetup for page size and margins
* @returns {{V: Array, H: Array}} candidates as { pos, kind }
*/
export function buildGuideCandidates(elements, excludeIds, pageSetup) {
const excluded = new Set(excludeIds || []);
const V = [];
const H = [];
(elements || []).forEach(el => {
if (excluded.has(el.id)) return;
if (el.type === 'a4-band') return;
const b = getA4Bounds(el);
V.push(
{ pos: b.left, kind: 'element' },
{ pos: b.centerX, kind: 'element-center' },
{ pos: b.right, kind: 'element' }
);
H.push(
{ pos: b.top, kind: 'element' },
{ pos: b.middleY, kind: 'element-center' },
{ pos: b.bottom, kind: 'element' }
);
});
const size = getPageSize(pageSetup);
const m = (pageSetup && pageSetup.margins) || {};
V.push(
{ pos: 0, kind: 'page' },
{ pos: size.w / 2, kind: 'page-center' },
{ pos: size.w, kind: 'page' },
{ pos: m.left || 0, kind: 'margin' },
{ pos: size.w - (m.right || 0), kind: 'margin' }
);
H.push(
{ pos: 0, kind: 'page' },
{ pos: size.h / 2, kind: 'page-center' },
{ pos: size.h, kind: 'page' },
{ pos: m.top || 0, kind: 'margin' },
{ pos: size.h - (m.bottom || 0), kind: 'margin' }
);
return { V, H };
}
/**
* Best snap on one axis.
*
* The dragged rect offers three of its own edges (start / centre / end); every
* one is compared against every candidate and the single smallest gap wins. The
* correction returned is applied to the rect's ORIGIN, so it is the same value
* whichever of the three edges matched — `candidate.pos - myEdgePos` shifts the
* whole box by exactly the amount that puts that edge on the line.
*
* Ties keep the first match found, which puts element candidates ahead of page
* ones (they are pushed first) — aligning to a neighbour is the more intentional
* act when both are available at the same distance.
*/
function bestOnAxis(start, end, candidates, tolMm) {
const center = (start + end) / 2;
const myEdges = [
{ pos: start, edge: 'start' },
{ pos: center, edge: 'center' },
{ pos: end, edge: 'end' }
];
let best = null;
for (const c of candidates) {
for (const mine of myEdges) {
const dist = Math.abs(mine.pos - c.pos);
if (dist > tolMm) continue;
if (best === null || dist < best.dist) {
best = { dist, delta: c.pos - mine.pos, pos: c.pos, kind: c.kind };
}
}
}
return best;
}
/**
* Resolve a dragged rect against the candidate set.
*
* @param {Object} draftRect { x, y, width, height } in mm — the unsnapped
* position the drag would otherwise commit
* @param {Object} candidates from buildGuideCandidates
* @param {number} zoom current canvas zoom; converts SNAP_PX to mm
* @returns {{x, y, guides}} snapped origin and the lines to draw. `guides` is
* empty when nothing was within reach, so the caller
* can clear the overlay without a separate check.
*/
export function resolveSnap(draftRect, candidates, zoom) {
const tolMm = snapToleranceMm(zoom);
const { x, y, width = 0, height = 0 } = draftRect;
const v = bestOnAxis(x, x + width, candidates.V, tolMm);
const h = bestOnAxis(y, y + height, candidates.H, tolMm);
const guides = [];
if (v) guides.push({ axis: 'v', pos: v.pos, kind: v.kind });
if (h) guides.push({ axis: 'h', pos: h.pos, kind: h.kind });
return {
x: v ? x + v.delta : x,
y: h ? y + h.delta : y,
guides
};
}
/**
* Union of several elements' exclusive bounds, as a { x, y, width, height } rect.
*
* Multi-select drags snap this single rect and apply the resulting delta to
* every member. Snapping each element independently would let them find
* different candidates and tear the group apart.
*/
export function unionRect(boxes) {
const left = Math.min(...boxes.map(b => b.x));
const top = Math.min(...boxes.map(b => b.y));
const right = Math.max(...boxes.map(b => b.x + (b.width || 0)));
const bottom = Math.max(...boxes.map(b => b.y + (b.height || 0)));
return { x: left, y: top, width: right - left, height: bottom - top };
}
@@ -0,0 +1,316 @@
/**
* The A4 layout engine.
*
* A PURE function: no React, no DOM, no context. That is load-bearing, not
* stylistic — `printReport.js` calls it from outside any React tree, and a
* future Puppeteer service would call it server-side. Keep it that way.
*
* It is the single source of positioning: both the printed page and the
* on-screen preview consume its output, which is what makes "preview page
* count equals printed page count" a real guarantee rather than a coincidence.
*
* PHASE 6 SCOPE — bands, per-row data scoping and multi-page pagination.
*/
import { formatValue } from './formatValue';
import { resolveDBTextValue } from '../utils/dataResolver';
import { evaluateExpression } from '../utils/expressionEvaluator';
import { calculateBandInstances } from './a4BandRenderer';
import { getPageSize } from './units';
/**
* Turn an expression's `[objectKey.fieldPath]` reference into a value.
*
* Ported from `ExpressionField.jsx:79-88`. The split is on the FIRST dot, so
* `[vehicle.driver.name]` resolves objectKey='vehicle', fieldPath='driver.name'.
*
* `scope` is the row-aware resolver below, so the same expression syntax means
* "the band's current row" inside a band and "the report data" outside one.
*/
export function makeFieldResolver(data, rowScope = null) {
return (ref) => {
const inner = ref.slice(1, ref.length - 1);
const dotIndex = inner.indexOf('.');
if (dotIndex === -1) {
return resolveInScope(data, rowScope, '', inner);
}
return resolveInScope(data, rowScope, inner.slice(0, dotIndex), inner.slice(dotIndex + 1));
};
}
/**
* Field resolution with an optional band row in scope.
*
* The matrix editor gets the current row from `BandContext` via React. Here it
* is a plain argument — simpler, and the only way layout() can stay pure.
* `resolveDBTextValue` already supports a row scope through its
* (parentBandId, bandContext) parameters, so the row is handed to it in that
* shape rather than duplicating its resolution rules.
*
* Precedence: the band row wins. A binding that the row does not satisfy falls
* back to the top-level report data, so a detail row can still print e.g.
* `[vehicle.number]` from the surrounding document.
*/
function resolveInScope(data, rowScope, objectKey, fieldPath) {
if (rowScope) {
const fromRow = resolveDBTextValue(
data, objectKey, fieldPath, ROW_SCOPE_ID, { currentBandData: { [ROW_SCOPE_ID]: rowScope } }
);
if (fromRow !== '') return fromRow;
}
return resolveDBTextValue(data, objectKey, fieldPath);
}
/** Internal key for the synthetic single-entry band context above. */
const ROW_SCOPE_ID = '__a4_row__';
/**
* Text for one element. Data binding is resolved HERE, not in the renderer, so
* PrintDocument receives finished strings and stays dumb — and so a future
* server-side Puppeteer run needs no browser data layer at all.
*
* The geometry-only types (rect, hline, vline) deliberately fall through to '';
* they are positioned like everything else but carry no text.
*/
function resolveText(el, data, rowScope = null) {
switch (el.type) {
case 'a4-text':
return el.content ?? '';
case 'a4-dbtext': {
const raw = resolveInScope(data, rowScope, el.objectKey, el.fieldPath);
return formatValue(raw, el.dataType || 'general');
}
case 'a4-expr': {
const raw = evaluateExpression(el.expression, makeFieldResolver(data, rowScope));
return formatValue(raw, el.dataType || 'general');
}
default:
// Geometry-only types, and unknown types: an unknown type renders empty
// rather than throwing, so a half-migrated report still prints the
// elements it can.
return '';
}
}
/**
* Split laid-out band instances across pages.
*
* The model is the standard banded report (FastReport/Crystal): EVERYTHING
* keeps the position it was drawn at, and the only thing that moves anything is
* a band GROWING as it repeats per data row. Growth pushes whatever sits below
* it — later bands and static elements alike — down by the same delta, so the
* gaps the user drew survive.
*
* - Instances are ATOMIC. A band that does not fit starts a new page rather
* than being sliced, because half a table row on each side of a page break is
* never what anyone wants on a weighing ticket.
* - `header` is a COLUMN header, not a page header. It prints where it was
* drawn, and repeats at the top of a CONTINUATION page — a page reached
* because the flow overran, so the rows it captions carry on beneath it.
* - `footer` bands sit at `contentBottom - height` on every page.
* - `summary` emits once, after all other instances.
*
* @param {Array} instances from calculateBandInstances, in flow order
* @param {number} contentTop mm
* @param {number} contentBottom mm
* @param {Function} emit (pageIndex, instance, y) => void
* @returns {number} the number of pages used (>= 1)
*/
export function paginate(instances, contentTop, contentBottom, emit) {
const headers = instances.filter(i => i.bandType === 'header');
const footers = instances.filter(i => i.bandType === 'footer');
const summaries = instances.filter(i => i.bandType === 'summary');
// Everything that streams down the page, headers included: a header prints in
// its designed place first, and only REPEATS on a continuation page.
const flow = instances.filter(
i => i.bandType !== 'footer' && i.bandType !== 'summary'
);
const headerHeight = headers.reduce((sum, h) => sum + h.height, 0);
const footerHeight = footers.reduce((sum, f) => sum + f.height, 0);
let pageIndex = 0;
// Tracks the running bottom of placed content. It starts at contentTop so the
// first instance can sit at its design Y, however far down that is.
let cursorY = contentTop;
const flowBottom = () => contentBottom - footerHeight;
/** Repeat the column headers at the top of a continuation page. */
const startContinuationPage = (index) => {
let y = contentTop;
headers.forEach(h => {
emit(index, h, y);
y += h.height;
});
return y;
};
const place = (instance) => {
// Design position, unless growth above has already pushed past it. max()
// is the whole model in one line: nothing moves up, and anything below a
// grown band is displaced by exactly that growth.
const designY = typeof instance.y === 'number' ? instance.y : cursorY;
let y = Math.max(designY, cursorY);
// A band taller than the usable area can never fit anywhere; place it and
// let it clip rather than paging forever looking for room.
const usable = contentBottom - contentTop - headerHeight - footerHeight;
const fitsAnywhere = instance.height <= usable;
if (y + instance.height > flowBottom() && fitsAnywhere) {
pageIndex += 1;
y = startContinuationPage(pageIndex);
}
emit(pageIndex, instance, y);
cursorY = y + instance.height;
};
flow.forEach(place);
summaries.forEach(place);
const pageCount = pageIndex + 1;
// Footers last: positioned from the BOTTOM, so they need the final page
// count, and they repeat on every page.
for (let p = 0; p < pageCount; p += 1) {
let y = contentBottom - footerHeight;
footers.forEach(f => {
emit(p, f, y);
y += f.height;
});
}
return pageCount;
}
/**
* @param {Array} elements report elements, positions in mm floats
* @param {Object} data resolved report data
* @param {Object} pageSetup { page, orientation, margins, defaultFont, defaultFontSize }
* @returns {{ pages: Array<Array<{ el, x, y, text }>> }}
*/
export function layout(elements, data, pageSetup) {
const list = Array.isArray(elements) ? elements : [];
const bands = list.filter(el => el && el.type === 'a4-band');
const pageSize = getPageSize(pageSetup);
const margins = pageSetup?.margins || {};
const contentTop = margins.top || 0;
const contentBottom = pageSize.h - (margins.bottom || 0);
// Ids of every element owned by a band — they are positioned by their band's
// instance, never at their own top-level y.
const childIds = new Set();
bands.forEach(b => (b.children || []).forEach(id => childIds.add(id)));
const statics = list.filter(
el => el && el.type !== 'a4-band' && !childIds.has(el.id)
);
const pages = [[]];
const ensurePage = (index) => {
while (pages.length <= index) pages.push([]);
return pages[index];
};
// How far the flow was displaced past a given design Y, and onto which page.
// Built as bands are placed, then applied to the statics below them: a static
// drawn under a band keeps its drawn gap when that band grows with its rows.
// Each entry is { designEnd, actualEnd, pageIndex } for one placed instance.
const displacements = [];
if (bands.length > 0) {
const instances = calculateBandInstances(bands, data, list);
paginate(instances, contentTop, contentBottom, (pageIndex, instance, y) => {
const target = ensurePage(pageIndex);
// Keyed on the band's DESIGN bottom (originalBandY + height), not on the
// instance's own y: calculateBandInstances has already stacked the repeats,
// so instance.y is where row N landed, while every row of one band shares
// a single design position. A static is drawn below the band as drawn.
if (typeof instance.originalBandY === 'number') {
displacements.push({
designEnd: instance.originalBandY + instance.height,
actualEnd: y + instance.height,
pageIndex
});
}
// A child's stored x and y are BOTH relative to its band — the canvas
// renders children inside the band div, so CSS adds the band's origin.
// layout() has to add it explicitly or a child prints measured from the
// paper edge instead of from the band's left margin.
const bandX = instance.bandX || 0;
instance.childElements.forEach(child => {
target.push({
// A child is emitted once per instance, so its key must be unique per
// instance — PrintDocument keys on el.id, and React would otherwise
// see duplicate keys across 60 detail rows.
el: child,
x: bandX + child.x,
// The child's stored y is RELATIVE to its band, exactly as the matrix
// editor stores it.
y: y + child.y,
text: resolveText(child, data, instance.data),
instanceKey: `${instance.bandId}-${instance.instanceIndex}-${pageIndex}`
});
});
});
}
/**
* Place a static, pushed down by any band growth above it.
*
* The governing band is the LAST one whose designed bottom is at or above the
* static: that is the thing it was drawn beneath. The static then keeps the
* exact gap it was drawn with, measured from that band's ACTUAL bottom.
*
* A static above every band has no governing band and so does not move —
* a title or letterhead stays where it was put.
*/
const placeStatic = (el) => {
let governing = null;
for (const d of displacements) {
if (d.designEnd > el.y) continue;
// Deepest design position wins; on a TIE take the one that ended up
// furthest down. Every instance of one repeating band shares a designEnd,
// so a tie is the normal case — keeping the first would measure the gap
// from row 1 and let the static sit on top of rows 2..n.
if (!governing ||
d.designEnd > governing.designEnd ||
(d.designEnd === governing.designEnd && d.actualEnd > governing.actualEnd)) {
governing = d;
}
}
if (!governing) {
pages[0].push({ el, x: el.x, y: el.y, text: resolveText(el, data) });
return;
}
const gap = el.y - governing.designEnd;
let y = governing.actualEnd + gap;
let pageIndex = governing.pageIndex;
// Pushed past the bottom margin: carry on at the top of the next page
// rather than vanishing off the sheet.
if (y + (el.height || 0) > contentBottom) {
pageIndex += 1;
y = contentTop;
}
ensurePage(pageIndex).push({ el, x: el.x, y, text: resolveText(el, data) });
};
statics.forEach(placeStatic);
return { pages };
}
export { formatValue };
export default layout;
@@ -0,0 +1,107 @@
import React from 'react';
import { getFontStack } from '../fonts';
import './print.css';
/**
* Renders layout() output as absolutely-positioned millimetre HTML.
*
* DELIBERATELY DUMB. All positioning and all data resolution happened in
* layout(); this maps finished {el, x, y, text} records onto CSS mm and does
* nothing else — no hooks, no context, no state, no CSS-in-JS runtime. That is
* what lets a future Puppeteer service consume this exact markup by POSTing the
* serialised HTML, instead of needing a redesign (see the plan's §6 "socket").
*
* Font size is emitted in CSS `pt` with no conversion at all: the element
* stores pt, CSS has real pt units, and the print path is physical. The screen
* path in A4TextField.jsx is the one that has to convert (pt -> px * zoom).
*
* The same FONT_STACKS strings are used on screen and here, so WYSIWYG holds by
* construction — one font stack, one rendering engine.
*/
/**
* Inline style for one placed element.
*
* Every branch emits TRUE millimetres. The screen renderers floor hairlines at
* 1px so they stay visible when zoomed out; nothing of the sort happens here,
* because the printed thickness is the thickness the user measures.
*/
export function styleFor(el, x, y) {
const base = { left: `${x}mm`, top: `${y}mm` };
switch (el.type) {
case 'a4-rect':
return {
...base,
width: `${el.width}mm`,
height: `${el.height}mm`,
// box-sizing: border-box (print.css) keeps the border INSIDE the
// declared mm, so the rect's outer footprint is what was designed.
border: el.borderWidth > 0
? `${el.borderWidth}mm solid ${el.borderColor || '#000000'}`
: 'none',
background: el.fillColor || 'transparent'
};
case 'a4-hline':
return {
...base,
width: `${el.width}mm`,
height: `${el.thickness}mm`,
background: el.color || '#000000'
};
case 'a4-vline':
return {
...base,
width: `${el.thickness}mm`,
height: `${el.height}mm`,
background: el.color || '#000000'
};
default:
// Text types: a4-text, a4-dbtext, a4-expr. They differ only in where
// their string came from, which layout() already settled.
return {
...base,
width: `${el.width}mm`,
height: `${el.height}mm`,
fontFamily: getFontStack(el.fontFamily),
fontSize: `${el.fontSize}pt`,
fontWeight: el.bold ? 700 : 400,
fontStyle: el.italic ? 'italic' : 'normal',
textAlign: el.align || 'left',
color: el.color || '#000000'
};
}
}
function PrintDocument({ pages, pageSetup }) {
const isLandscape = pageSetup?.orientation === 'landscape';
const pageClass = `a4-print-page${isLandscape ? ' a4-print-page--landscape' : ''}`;
return (
<div className="print-root">
{(pages || []).map((els, i) => (
<div className={pageClass} key={i}>
{els.map(({ el, x, y, text, instanceKey }) => (
<div
className={[
'a4-print-el',
`a4-print-el--${el.type}`,
el.className || ''
].filter(Boolean).join(' ')}
// A band child appears once per data row, so el.id alone is NOT
// unique on the page — layout() supplies instanceKey for those.
key={instanceKey ? `${instanceKey}-${el.id}` : el.id}
style={styleFor(el, x, y)}
>
{text}
</div>
))}
</div>
))}
</div>
);
}
export default PrintDocument;
@@ -0,0 +1,103 @@
/**
* Print styles for the A4 designer.
*
* Millimetre accuracy comes from CSS physical units — Chrome maps `mm` to real
* millimetres at 100% scale. This is the whole reason there is no PDF library:
* the browser prints with the fonts installed on the station, and "Save as PDF"
* is the operator choosing that destination in the same dialog.
*
* TWO NAMING NOTES, both deliberate:
*
* 1. The page class is `.a4-print-page`, NOT `.a4-page`. A4Canvas.css already
* styles `.a4-page` for the on-screen editor canvas (background, shadow),
* and both stylesheets are loaded while the editor is open — sharing the
* name would make them fight.
*
* 2. `.print-root` must sit on the element that is a DIRECT CHILD OF <body>,
* because the hiding rule below is `body > *:not(.print-root)`. printReport.js
* therefore puts the class on the container it appends to document.body, and
* PrintDocument repeats it on its own outer div for the standalone case.
* Get this wrong and you print either a blank page or the entire app.
*/
@page {
size: A4 portrait;
margin: 0;
}
/* Landscape: `@page { size }` cannot be set from an inline style or a class, so
printReport.js injects a one-line `@page { size: A4 landscape; margin: 0 }`
stylesheet that overrides the rule above. The page BOX still needs swapped
dimensions, which is what .a4-print-page--landscape below does. */
.a4-print-page {
width: 210mm;
height: 297mm;
position: relative;
overflow: hidden;
background: #fff;
page-break-after: always;
break-after: page;
}
.a4-print-page:last-child {
page-break-after: auto;
break-after: auto;
}
.a4-print-page--landscape {
width: 297mm;
height: 210mm;
}
.a4-print-el {
position: absolute;
/* index.css sets `* { box-sizing: border-box }` globally; with no padding or
border on these boxes that is a no-op, but state it so a future border
(Phase 5's a4-rect) does not silently change the printed width. */
box-sizing: border-box;
margin: 0;
padding: 0;
/* Print exactly the designed colours; without this Chrome may drop
backgrounds and lighten text in some print paths. */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Geometry-only types carry no text, so they must not inherit a line-height or
font metric that could add a fraction of a millimetre to the painted box.
The height of an hline IS its measured thickness. */
.a4-print-el--a4-rect,
.a4-print-el--a4-hline,
.a4-print-el--a4-vline {
font-size: 0;
line-height: 0;
}
/* NOTE: there is deliberately no `.a4-calibration-mark` rule here any more.
The calibration marks are now REAL a4-hline / a4-vline elements that paint
their own background from `color`, so a class-level background would only
be able to contradict the element's own colour. print-color-adjust above is
what keeps them solid black on paper. */
/* Hidden on screen — the print root is mounted into the live DOM, so it must
not appear over the editor. */
.print-root {
display: none;
}
@media print {
body > *:not(.print-root) {
display: none !important;
}
.print-root {
display: block !important;
}
body {
margin: 0 !important;
padding: 0 !important;
background: #fff !important;
}
}
@@ -0,0 +1,118 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
import PrintDocument from './PrintDocument';
import { layout } from '../layout';
/**
* Non-React entry point for printing an A4 report.
*
* Callable from anywhere — Main.jsx will call it in Phase 7 with no editor
* mounted. That works only because layout() is pure, so nothing here needs a
* React context, a provider, or the component tree.
*
* "Save as PDF" is THE SAME CALL: the operator picks that destination in
* Chrome's own print dialog. There is no second code path and no PDF library —
* which is what lets the output use the fonts installed on the station.
*
* MOUNTING: the container is appended directly to <body> and carries the
* `print-root` class ITSELF. print.css hides everything else with
* `body > *:not(.print-root)`, which only matches direct children of body — so
* the class has to be on the appended container, not merely on PrintDocument's
* inner div. The app's own root (<div id="root">) is a sibling and gets hidden.
*/
/** Injected only for landscape: `@page { size }` cannot come from a class. */
const LANDSCAPE_STYLE_ID = 'a4-print-landscape-page-rule';
function applyLandscapePageRule() {
if (document.getElementById(LANDSCAPE_STYLE_ID)) return;
const style = document.createElement('style');
style.id = LANDSCAPE_STYLE_ID;
style.textContent = '@page { size: A4 landscape; margin: 0; }';
document.head.appendChild(style);
}
function removeLandscapePageRule() {
document.getElementById(LANDSCAPE_STYLE_ID)?.remove();
}
/**
* @param {Object} report { elements, pageSetup | page_setup }
* @param {Object} data resolved report data (used from Phase 5 onward)
* @returns {Promise<void>} resolves once the print container has been removed
*/
export async function printA4Report(report, data) {
// Accept both the editor's in-memory shape (pageSetup) and the API shape
// (page_setup), so the Print button works on an unsaved report and Main.jsx
// can pass a freshly fetched row unchanged.
const pageSetup = report?.pageSetup || report?.page_setup || {};
const { pages } = layout(report?.elements, data, pageSetup);
const container = document.createElement('div');
container.className = 'print-root';
document.body.appendChild(container);
if (pageSetup.orientation === 'landscape') applyLandscapePageRule();
const root = createRoot(container);
// window.print() reads the DOM synchronously, but React 18 commits its render
// asynchronously — so without flushSync the dialog can open on an EMPTY
// container and print a blank page. flushSync forces the commit to finish
// before print() is called.
//
// Deliberately not requestAnimationFrame: rAF does not fire in a background
// or hidden tab, which would leave a print click hanging forever instead of
// printing. flushSync has no such dependency on the frame loop.
flushSync(() => {
root.render(<PrintDocument pages={pages} pageSetup={pageSetup} />);
});
return new Promise(resolve => {
let done = false;
// Declared before cleanup() so that a synchronous afterprint — Chrome fires
// it from inside window.print() — does not hit the temporal dead zone.
let fallbackTimer = null;
const cleanup = () => {
if (done) return;
done = true;
window.removeEventListener('afterprint', cleanup);
clearTimeout(fallbackTimer);
removeLandscapePageRule();
// Unmount asynchronously: React forbids unmounting a root from inside a
// lifecycle/event it is already rendering within.
setTimeout(() => {
try {
root.unmount();
} catch (err) {
// The container may already have been detached (a host page tearing
// down, a test harness cleaning up). Removing it is what actually
// matters, so never let an unmount error skip that.
console.error('Print teardown failed to unmount cleanly:', err);
}
container.remove();
resolve();
}, 0);
};
window.addEventListener('afterprint', cleanup);
// Not every browser fires afterprint (and some never fire it if the dialog
// is dismissed). Without this fallback, containers accumulate in the DOM on
// every print — one full report's markup each time.
fallbackTimer = setTimeout(cleanup, 60000);
try {
window.print();
} catch (err) {
// A blocked or unavailable print dialog must still tear the container
// down, or the editor is left with hidden markup stuck in <body>.
console.error('Print failed:', err);
cleanup();
}
});
}
export default printA4Report;
@@ -0,0 +1,68 @@
/**
* Sample vehicle data for designing bindings when no real vehicle was passed.
*
* DUPLICATED, NOT EXTRACTED. The plan (§3) preferred moving this out of
* `ReportEditor.jsx:71-116` into a shared `utils/sampleData.js` so the two
* editors could not drift. `ReportEditor.jsx` is on the protected list for this
* phase, and extracting means editing it, so the plan's own fallback applies:
* duplicate instead. The matrix copy remains the original; if that file is ever
* unfrozen, delete this and import the shared one.
*
* Kept byte-for-byte equivalent to the matrix version — do not "improve" it, or
* a report designed against A4 sample data will bind differently from the same
* report designed in the matrix editor.
*/
/**
* Build a report-data object from nomenclature definitions/entries.
*
* @param {Object} definitions code → nomenclature definition (from NomenclatureDataContext)
* @param {Object} entries code → array of entries for lookup nomenclatures
* @returns {Object} `{ vehicle: {...} }`, the shape DataContext expects
*/
export function buildSampleVehicleData(definitions, entries) {
const extra = {};
for (const def of Object.values(definitions || {})) {
if (def.kind === 'lookup') {
const activeEntries = ((entries || {})[def.code] || []).filter(e => e.is_active);
const firstEntry = activeEntries[0];
if (firstEntry?.data && Object.keys(firstEntry.data).length > 0) {
extra[def.name] = { ...firstEntry.data };
} else if (def.fields?.length > 0) {
const sample = {};
for (const field of def.fields) {
if (field.field_type === 'number') sample[field.key] = 0;
else if (field.field_type === 'bool') sample[field.key] = false;
else sample[field.key] = 'Sample';
}
extra[def.name] = sample;
} else {
extra[def.name] = {};
}
} else {
extra[def.name] = 'Sample Text';
}
}
return {
vehicle: {
vehicle_number: 'АА1234ВВ',
trailer1_number: 'ПВ5678ВВ',
trailer2_number: null,
driver_pid: '123456789',
tare: 8500,
tare_date: '2026-02-22T08:00:00Z',
tare_user_name: 'operator',
gross: 28500,
gross_date: '2026-02-22T10:30:00Z',
gross_user_name: 'operator',
net: 20000,
net_date: '2026-02-22T10:30:00Z',
net_user_name: 'operator',
doc_number: '2026-001',
extra
}
};
}
export default buildSampleVehicleData;
@@ -0,0 +1,34 @@
/**
* Unit conversions for the A4 designer.
*
* Storage is ALWAYS millimetre floats — never pixels, never zoom-affected values.
* Pixels exist only at render time; `zoom` never reaches the stored report JSON.
*
* Font size is stored in pt (what users expect). On screen it renders as
* pt * (96/72) * zoom px; on the print path CSS `pt` is used directly.
*/
export const PX_PER_MM = 96 / 25.4; // 3.7795 — CSS px per mm at 100% zoom
export const PX_PER_PT = 96 / 72; // 1.3333 — CSS px per pt 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);
/** Screen pixel size for a pt font at the current zoom. */
export const ptToPx = (pt, zoom = 1) => pt * PX_PER_PT * zoom;
/** 0.1 mm storage precision — keeps 12.700000000000001 out of the JSON. */
export const roundMm = (mm) => Math.round(mm * 10) / 10;
export const PAGE = {
A4: {
portrait: { w: 210, h: 297 },
landscape: { w: 297, h: 210 }
}
};
/** Page dimensions in mm for a page_setup object. */
export function getPageSize(pageSetup) {
const page = PAGE[pageSetup?.page] || PAGE.A4;
return page[pageSetup?.orientation === 'landscape' ? 'landscape' : 'portrait'];
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
/**
* Custom hook for handling element dragging with grid snapping
@@ -13,15 +13,30 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
const [isDragging, setIsDragging] = useState(false);
const [dragData, setDragData] = useState(null);
// onDrag and dragData are read through refs so the effect below can depend on
// `isDragging` ALONE. Depending on them directly re-ran the effect on every
// render during a drag, and each re-run removed the mouseup listener before
// adding a new one. A mouseup landing in that gap was lost and the element
// kept following the mouse until the next click. Snapping made it routine:
// a held guide line stops x/y changing, so the only state still changing is
// activeGuides, and that render lands between the mousemove and the mouseup.
const onDragRef = useRef(onDrag);
onDragRef.current = onDrag;
const dragDataRef = useRef(dragData);
dragDataRef.current = dragData;
const handleMouseDown = (e, elementId) => {
// Only handle dragging in select mode
if (toolMode !== 'select') return;
e.stopPropagation();
setIsDragging(true);
const data = onDragStart(elementId, e.clientX, e.clientY);
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
// Seed the ref too: the listeners below subscribe on this same render pass,
// so a mousemove can arrive before the state write has been applied.
dragDataRef.current = { ...data, startMouseX: e.clientX, startMouseY: e.clientY };
setIsDragging(true);
setDragData(dragDataRef.current);
};
useEffect(() => {
@@ -31,16 +46,18 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
document.body.classList.add('dragging-active');
const handleMouseMove = (e) => {
if (dragData) {
const deltaX = e.clientX - dragData.startMouseX;
const deltaY = e.clientY - dragData.startMouseY;
onDrag(dragData, deltaX, deltaY);
const data = dragDataRef.current;
if (data) {
const deltaX = e.clientX - data.startMouseX;
const deltaY = e.clientY - data.startMouseY;
onDragRef.current(data, deltaX, deltaY);
}
};
const handleMouseUp = () => {
setIsDragging(false);
setDragData(null);
dragDataRef.current = null;
document.body.classList.remove('dragging-active');
};
@@ -52,7 +69,7 @@ export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
document.removeEventListener('mouseup', handleMouseUp);
document.body.classList.remove('dragging-active');
};
}, [isDragging, dragData, onDrag]);
}, [isDragging]);
return {
isDragging,
@@ -0,0 +1,365 @@
.scale-monitor {
min-height: 100vh;
background-color: #f5f5f7;
}
.sm-body {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.sm-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.sm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
cursor: pointer;
font-size: 13px;
}
.sm-back:hover {
border-color: #667eea;
color: #667eea;
}
.sm-conn {
font-size: 12px;
font-weight: 600;
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.sm-conn--on {
background-color: #e8f5e9;
color: #2e7d32;
}
.sm-conn--off {
background-color: #fee;
color: #c62828;
}
.sm-status {
font-size: 12px;
color: #888;
font-family: 'Courier New', monospace;
}
.sm-message {
padding: 8px 12px;
background-color: #eef0ff;
border: 1px solid #ccd;
border-radius: 6px;
color: #445;
font-size: 13px;
margin-bottom: 12px;
}
.sm-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1000px) {
.sm-columns {
grid-template-columns: 1fr;
}
}
.sm-panel {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.sm-panel--wide {
margin-top: 16px;
}
.sm-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sm-panel-head h3 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-panel-actions {
display: flex;
gap: 6px;
}
.sm-panel-actions button {
padding: 4px 10px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 12px;
cursor: pointer;
}
.sm-panel-actions button:hover {
border-color: #667eea;
color: #667eea;
}
.sm-send {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.sm-send input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
}
.sm-send button {
padding: 6px 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
cursor: pointer;
}
.sm-send button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sm-lines {
height: 420px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 6px;
background-color: #fafafa;
}
.sm-line {
padding: 5px 8px;
border-bottom: 1px solid #eee;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.sm-line:hover {
background-color: #f0f0ff;
}
.sm-line--selected {
background-color: #e8eaff;
}
.sm-line-text {
display: flex;
justify-content: space-between;
gap: 8px;
color: #333;
}
.sm-visible-space {
white-space: pre;
word-break: break-all;
}
.sm-len {
color: #aaa;
flex-shrink: 0;
}
.sm-line-hex {
color: #999;
font-size: 11px;
word-break: break-all;
margin-top: 2px;
}
.sm-empty {
padding: 20px;
text-align: center;
color: #999;
font-style: italic;
font-size: 13px;
}
.sm-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.sm-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.sm-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-form input[type="text"],
.sm-form input[type="number"] {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
text-transform: none;
letter-spacing: normal;
font-weight: 400;
color: #333;
}
.sm-check {
flex-direction: row !important;
align-items: center;
gap: 6px;
text-transform: none !important;
letter-spacing: normal !important;
font-weight: 400 !important;
color: #555 !important;
font-size: 12px !important;
}
.sm-hint {
font-size: 11px;
color: #999;
font-style: italic;
}
.sm-preview {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #eee;
}
.sm-preview h4 {
margin: 0 0 8px 0;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-preview-ok {
display: flex;
align-items: baseline;
gap: 10px;
}
.sm-preview-weight {
font-size: 24px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: #333;
}
.sm-bal {
color: #2e7d32;
font-size: 12px;
font-weight: 600;
}
.sm-unbal {
color: #c62828;
font-size: 12px;
font-weight: 600;
}
.sm-preview-error {
color: #c62828;
font-size: 13px;
font-style: italic;
}
.sm-save {
margin-top: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #667eea, #764ba2);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.sm-save:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
}
.sm-protocols {
display: flex;
flex-direction: column;
gap: 6px;
}
.sm-protocol {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #eee;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.sm-protocol:hover {
background-color: #f0f0ff;
}
.sm-protocol-name {
font-weight: 600;
color: #333;
}
.sm-protocol-kind,
.sm-protocol-poll {
font-size: 11px;
color: #999;
font-family: 'Courier New', monospace;
}
.sm-protocol-poll {
margin-left: auto;
}

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