Compare commits
21 Commits
ac6b1f48b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cf4c74845 | |||
| 4b0739ba06 | |||
| 32aa2eeda9 | |||
| 82f3a674a3 | |||
| 68cf541bbe | |||
| ee599978da | |||
| 63056f5b42 | |||
| 6f614270e7 | |||
| 122480cd46 | |||
| ddc128dba4 | |||
| edfe2f1bfe | |||
| c98c0eef4a | |||
| d10b1d263f | |||
| fe9fb71860 | |||
| 0a1b7c31e2 | |||
| 6fe9f1820c | |||
| 5a8527e96e | |||
| 67c9531600 | |||
| 4b6138f049 | |||
| 183908cbab | |||
| 19945dae19 |
@@ -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.
@@ -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,88 @@
|
||||
# CSS design tokens — typography, spacing, and full colour migration
|
||||
|
||||
## Context
|
||||
`src/styles/tokens.css` already defines semantic colour/radius/shadow tokens for
|
||||
two themes (`classic`, `cockpit`). But:
|
||||
- there are no font or spacing tokens: ~25 distinct font-sizes (7px…40px, px and
|
||||
rem mixed), 9 font-family declarations, ~25 distinct padding/margin/gap values;
|
||||
- about half the stylesheets still hardcode colours (`#667eea` ×88, `#333` ×42 …),
|
||||
so those screens ignore the theme — they stay light under `cockpit`;
|
||||
- `UserManager.jsx` styles itself entirely with inline `style={{}}`.
|
||||
|
||||
Goal: every UI value (colour, font, size, weight, spacing) comes from a token, so
|
||||
the look is changed in one file. Report *output* keeps fixed values.
|
||||
|
||||
## What stays fixed (not tokenised)
|
||||
Printed/rendered report output must look identical regardless of screen theme:
|
||||
- `ReportEditor/a4/a4Elements.css`, `a4/print/print.css`, `ReportEditor/print/print.css`,
|
||||
`ReportEditor/elements.css` (text-mode report elements on the page)
|
||||
- colours in A4 element JSX (`a4/elements/*`), `calibrationReport.js`, `sampleData.js`, tests
|
||||
- the "paper" surface itself (white page in `A4Canvas.css` / `EditorCanvas.css`)
|
||||
|
||||
The report **editor chrome** around it (toolbar, inspectors, config panel, character
|
||||
palette, kind chooser, page setup, selection handles/guides) *does* get tokens.
|
||||
|
||||
Also left alone: `components/concepts/ScalesAppCyberpunk.css` (standalone mockup
|
||||
with its own private vars). `DataDisplay.jsx/.css` is not imported anywhere — dead
|
||||
code, flagged, not touched.
|
||||
|
||||
## New tokens (in `:root` only — theme-independent, both themes share them)
|
||||
```css
|
||||
/* Type */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'Courier New', Consolas, monospace;
|
||||
|
||||
--fs-xs: 11px; --fs-sm: 12px; --fs-base: 13px; --fs-md: 14px;
|
||||
--fs-lg: 16px; --fs-xl: 18px; --fs-2xl: 22px; --fs-3xl: 28px;
|
||||
--fs-display: 32px; /* big figures: the weight reading etc. */
|
||||
|
||||
--fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700;
|
||||
|
||||
/* Spacing — 4px grid with the half-steps this UI actually uses a lot (6, 10) */
|
||||
--space-0-5: 2px; --space-1: 4px; --space-1-5: 6px; --space-2: 8px;
|
||||
--space-2-5: 10px; --space-3: 12px; --space-4: 16px; --space-5: 20px;
|
||||
--space-6: 24px; --space-8: 32px; --space-10: 40px;
|
||||
```
|
||||
A theme may later override any of these (e.g. a larger `--fs-display` for cockpit).
|
||||
|
||||
**Snapping rule:** off-scale values snap to the nearest step (5→4/6, 7→6/8,
|
||||
14→12/16, 15/17→16, 20→22 for font, rem values converted at 16px). Expect ≤2px
|
||||
drift in places — that's the consistency we're buying. Values that are
|
||||
geometry rather than rhythm (1px borders, icon/checkbox sizes, fixed widths,
|
||||
`letter-spacing`) stay literal.
|
||||
|
||||
**Colour mapping** for stray literals (add a token only if nothing fits):
|
||||
`#667eea`→`--accent`, `#764ba2` gradients→`--accent-gradient`,
|
||||
`#333/#2c3e50`→`--text`, `#555/#666/#888/#999`→`--text-muted`,
|
||||
`#ddd/#e0e0e0/#dee2e6`→`--border`, `#f5f5f5/#fafafa/#f0f0f0`→`--surface-sunken`/`--surface-hover`,
|
||||
`white/#fff`→`--surface`, `#fee/#c33`→`--danger-soft`/`--danger`, greens/oranges→`--success*`/`--warning*`.
|
||||
Likely one new token: `--backdrop` (modal overlay dim).
|
||||
|
||||
## Phases (one commit each; done via `/dispatch` → react-expert-dev)
|
||||
1. **Tokens** — add the block above to `tokens.css`; `index.css` body uses
|
||||
`--font-sans`, `code` uses `--font-mono`.
|
||||
2. **Screens already on colour tokens** — swap font/spacing literals:
|
||||
`Main.css`, `ScaleMonitor.css`, `Header.css`, `History.css`,
|
||||
`ExtraCombinationsTable.css`, `App.css`, `ReportPreviewModal.css`.
|
||||
3. **Hardcoded screens** — colours + fonts + spacing:
|
||||
`NomenclatureManager.css`, `NomenclatureUI/*.css` (ManagementOverlay, EntryForm,
|
||||
Dropdown, FormOverlay), `LicenseManager.css`, `Login.css`, `ChangePasswordOverlay.css`.
|
||||
`UserManager.jsx`: move inline styles into a new `UserManager.css` (reuse existing
|
||||
Main.css classes like `.extra-field`, `.vehicle-form-actions` where they already fit).
|
||||
4. **Report editor chrome** — `ReportEditor.css`, `Toolbar.css`, `ObjectInspector.css`,
|
||||
`ConfigPanel.css`, `CharacterPalette.css`, `EditorCanvas.css` (non-paper parts),
|
||||
`a4/A4Canvas.css` (non-paper), `A4Inspector.css`, `A4PageSetup.css`,
|
||||
`A4KindChooser.css`, `A4ReportEditor.css`; chrome colours in `A4Canvas.jsx` /
|
||||
`A4Inspector.jsx` / `ObjectInspector.jsx` (guides, handles) → `var(--…)`.
|
||||
|
||||
## Verification
|
||||
- After each phase: grep for `#[0-9a-f]{3,6}`, `font-size: *[0-9]`, px spacing in the
|
||||
touched files → only allowed literals remain.
|
||||
- End state: hex colours exist only in `tokens.css`, the "fixed" report files, and the
|
||||
concept page.
|
||||
- `npm run build` passes.
|
||||
- Visual: screenshots (Playwright, saved to `.claude/screenshots/`) of main screen,
|
||||
nomenclature manager, login, licence, user manager, both report editors — in
|
||||
`classic` and `cockpit`, before and after. Classic should look the same (±snapping);
|
||||
cockpit should now be dark on the previously-light screens.
|
||||
- Test suite only when you say "ready for testing".
|
||||
@@ -0,0 +1,321 @@
|
||||
# Quick-Fill Combinations for Vehicle Additional Data
|
||||
|
||||
## Context
|
||||
|
||||
Trucks frequently arrive one after another carrying the same cargo, from the same speditor, via the same agent. Today the operator re-selects every dropdown in the "Additional Data" section of the vehicle edit form for each truck, even when nothing changed from the previous one.
|
||||
|
||||
This adds a helper table above those fields listing previously-used *combinations* of the lookup values. One click fills all the dropdowns. Rows are created automatically when a vehicle's extra data is saved, and the user deletes rows they no longer need.
|
||||
|
||||
**Decisions made with the user:**
|
||||
- A "combination" = only `kind='lookup'` nomenclature values. Free-text/number/bool fields are excluded so one-off notes don't pollute the table.
|
||||
- Re-using an existing combination bumps its `last_used` and increments `use_count`. No duplicate rows.
|
||||
- Rows are shared by all users (global, not per-user).
|
||||
- Rows persist indefinitely — no cap, no auto-trim. Manual delete only.
|
||||
- Table renders inline, always visible, directly above the existing fields, in **both** the new-vehicle and edit forms.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. Model — `backend/vehicles/models.py`
|
||||
|
||||
Lives in `vehicles/` because it's a usage history of `VehicleExtra.data`, a domain model. (`api/` holds the API layer; `vehicles/views.py|serializers.py|urls.py` are dead, broken, unrouted code — do not touch them.)
|
||||
|
||||
Dedup uses a canonical hash, not JSONField equality: on SQLite a `JSONField` exact-match compares the serialized string, so `{"A":1,"B":2}` ≠ `{"B":2,"A":1}`. A `unique=True` SHA-256 column is backend-agnostic and O(1).
|
||||
|
||||
Add `import hashlib` / `import json` at the top, then:
|
||||
|
||||
```python
|
||||
def combination_hash(data):
|
||||
"""Order-independent canonical hash of a {nomenclature_code: entry_id} dict."""
|
||||
canonical = json.dumps(data, sort_keys=True, separators=(',', ':'))
|
||||
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class ExtraCombination(models.Model):
|
||||
"""A previously-used combination of lookup nomenclature values for a vehicle's extra data."""
|
||||
data = models.JSONField(default=dict)
|
||||
data_hash = models.CharField(max_length=64, unique=True, editable=False)
|
||||
use_count = models.IntegerField(default=1)
|
||||
last_used = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-last_used"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.data_hash = combination_hash(self.data)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.data} (x{self.use_count})"
|
||||
```
|
||||
|
||||
Computing the hash inside `save()` means it can never drift from `data`. `auto_now_add` (not `auto_now`) + an explicit bump in the recorder keeps the timestamp meaning "last used", not "last saved".
|
||||
|
||||
Migration: `python manage.py makemigrations vehicles` → `0005_extracombination.py` (latest is currently `0004_`).
|
||||
|
||||
### 2. Serializer — `backend/api/serializers.py`
|
||||
|
||||
Extract the existing validation at [api/serializers.py:85-123](backend/api/serializers.py#L85-L123) into a module-level function with a `lookup_only` flag, so both serializers share it. Behaviour for the existing path is bit-identical.
|
||||
|
||||
```python
|
||||
def validate_vehicle_extra_data(value, lookup_only=False):
|
||||
"""Validate a {nomenclature_code: value} dict against vehicle nomenclatures.
|
||||
|
||||
lookup_only=True rejects any key whose nomenclature is not kind='lookup'.
|
||||
"""
|
||||
```
|
||||
|
||||
Body is the current `validate_data` verbatim, with one insert in the `Nomenclature.FIELD` branch:
|
||||
|
||||
```python
|
||||
elif nom.kind == Nomenclature.FIELD:
|
||||
if lookup_only:
|
||||
errors[key] = "Only lookup nomenclatures are allowed here."
|
||||
continue
|
||||
field_def = nom.fields.first()
|
||||
# ...rest unchanged
|
||||
```
|
||||
|
||||
`VehicleExtraSerializer.validate_data` becomes `return validate_vehicle_extra_data(value)`.
|
||||
|
||||
New serializer (after `VehicleSerializer`):
|
||||
|
||||
```python
|
||||
class ExtraCombinationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ExtraCombination
|
||||
fields = ['id', 'data', 'use_count', 'last_used']
|
||||
read_only_fields = ['id', 'use_count', 'last_used']
|
||||
|
||||
def validate_data(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("Combination cannot be empty.")
|
||||
return validate_vehicle_extra_data(value, lookup_only=True)
|
||||
```
|
||||
|
||||
**Returns raw `{code: entry_id}` — no denormalized labels.** The frontend already has `getDisplayValue(code, entryId)` backed by an SSE-live cache, so labels update in realtime when an entry is renamed. Server-side labels would be a stale snapshot requiring re-broadcast of every row on every entry edit.
|
||||
|
||||
### 3. Recorder + ViewSet — `backend/api/views.py`
|
||||
|
||||
Imports: add `mixins` to the DRF import, `ExtraCombination` and `combination_hash` to the `vehicles.models` import, `ExtraCombinationSerializer` to the serializer import.
|
||||
|
||||
Helper above `VehicleViewSet` (next to `_maybe_finalize`, matching file convention):
|
||||
|
||||
```python
|
||||
def _record_extra_combination(extra_data):
|
||||
"""Record the lookup-only projection of a vehicle's extra data as a reusable combination."""
|
||||
if not extra_data:
|
||||
return
|
||||
|
||||
lookup_codes = set(
|
||||
Nomenclature.objects.filter(applies_to='vehicle', kind=Nomenclature.LOOKUP)
|
||||
.values_list('code', flat=True)
|
||||
)
|
||||
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
|
||||
if not combo:
|
||||
return
|
||||
|
||||
existing, created = ExtraCombination.objects.get_or_create(
|
||||
data_hash=combination_hash(combo), defaults={'data': combo}
|
||||
)
|
||||
if not created:
|
||||
existing.use_count += 1
|
||||
existing.last_used = timezone.now()
|
||||
existing.save()
|
||||
|
||||
action = 'created' if created else 'updated'
|
||||
sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data)
|
||||
```
|
||||
|
||||
`get_or_create` (rather than `filter().first()` + `create()`) closes the race where two users save the same brand-new combination simultaneously — otherwise one gets an `IntegrityError` 500 *after* the vehicle was already saved.
|
||||
|
||||
Two guards matter: no extra at all, and extra containing only `kind='field'` values (the common "user typed a one-off note" case). Neither should create a row.
|
||||
|
||||
ViewSet after `VehicleViewSet`:
|
||||
|
||||
```python
|
||||
class ExtraCombinationViewSet(mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
"""
|
||||
API endpoint for previously-used combinations of vehicle extra (lookup) data.
|
||||
|
||||
Rows are created/bumped automatically when a vehicle's extra data is saved.
|
||||
"""
|
||||
queryset = ExtraCombination.objects.all()
|
||||
serializer_class = ExtraCombinationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
ordering = ['-last_used']
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
data = ExtraCombinationSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('extra_combination', 'deleted', data)
|
||||
```
|
||||
|
||||
No create/update endpoints — rows only ever appear as a side effect of saving a vehicle.
|
||||
|
||||
Route in [api/urls.py](backend/api/urls.py):
|
||||
```python
|
||||
router.register(r'extra-combinations', views.ExtraCombinationViewSet, basename='extra-combination')
|
||||
```
|
||||
|
||||
### 4. Where recording fires — `VehicleViewSet`
|
||||
|
||||
**Backend, in `perform_create`/`perform_update`.** This is the layer that already owns side effects of a successful vehicle mutation (`sse_broadcast_update`), it covers both required paths (create-with-extra, and the `handleSaveExtra` PATCH which routes through `partial_update` → `perform_update`), and it can't be forgotten by frontend code.
|
||||
|
||||
Rejected: a frontend POST (two round-trips, fails independently, must be duplicated, wrong trust boundary); a `post_save` signal (would fire on fixtures, migrations, shell saves and admin edits, broadcasting SSE with no request context — and the project has zero signals today).
|
||||
|
||||
One line added to each, at [api/views.py:161-169](backend/api/views.py#L161-L169):
|
||||
|
||||
```python
|
||||
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
|
||||
```
|
||||
|
||||
`hasattr` matches the existing style at [api/serializers.py:162](backend/api/serializers.py#L162); `extra` is a reverse OneToOne that raises `DoesNotExist` when absent.
|
||||
|
||||
`set_tare`/`set_gross` bypass `perform_update` and don't touch extra data — leave them alone.
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### 5. New component — `frontend/src/components/ExtraCombinationsTable.jsx` + `.css`
|
||||
|
||||
Sits in `components/` next to `Main.jsx` (it's a Main-screen helper, not a nomenclature UI primitive).
|
||||
|
||||
Props: `{ nomenclatures, onApply }` — `nomenclatures` is the already-sorted `vehicleNomenclatures` from [Main.jsx:28](frontend/src/components/Main.jsx#L28) so columns line up with the dropdowns below; `onApply(data)` gets the raw dict.
|
||||
|
||||
**Data + realtime: local state + fetch on mount + `subscribeToUpdates`.** Extending `NOMENCLATURES_CONFIG` was rejected — that machinery sorts by `name`/`id` (not `-last_used`) and its handler has no `delete` branch, so it'd mean modifying shared load-bearing code for one consumer.
|
||||
|
||||
This needs **one token** added to [NomenclatureContext.jsx:362](frontend/src/contexts/NomenclatureContext.jsx#L362), since unknown objects currently hit `console.warn` and get dropped:
|
||||
|
||||
```jsx
|
||||
if (object === 'nomenclature' || object === 'nomenclature_entry' || object === 'extra_combination') {
|
||||
```
|
||||
|
||||
Strictly additive — existing subscribers already filter on `message.object` before acting.
|
||||
|
||||
**Stale-closure hazard:** `subscribeToUpdates` is not `useCallback`'d ([NomenclatureContext.jsx:344](frontend/src/contexts/NomenclatureContext.jsx#L344)), so a `useEffect` depending on it re-subscribes every provider render. Hold it in a ref and run the effect with `[]` deps. Leave a one-line comment so nobody "fixes" it back.
|
||||
|
||||
Key logic:
|
||||
|
||||
```jsx
|
||||
const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await api.get('/api/extra-combinations/');
|
||||
setCombinations(res.data.results || res.data); // PAGE_SIZE=100 envelope
|
||||
}, []);
|
||||
|
||||
// SSE
|
||||
if (message.object !== 'extra_combination') return;
|
||||
setCombinations(prev => operation === 'delete'
|
||||
? prev.filter(c => c.id !== data.id)
|
||||
: [data, ...prev.filter(c => c.id !== data.id)]); // most recent first
|
||||
```
|
||||
|
||||
Table: one `<th>` per lookup nomenclature (`nom.name`) plus a narrow actions column; rows call `onApply(combo.data)`; each row has a `×` delete button with `e.stopPropagation()` and a `window.confirm`. Cells resolve via `getDisplayValue(nom.code, entryId)`, rendering `#<id>` in muted italic when an id no longer resolves. Returns `null` if there are no lookup nomenclatures.
|
||||
|
||||
CSS follows the `nui-mgmt-*` table conventions from [NomenclatureManagementOverlay.css](frontend/src/components/NomenclatureUI/NomenclatureManagementOverlay.css), scaled down for inline use: sticky header on `#f8f9fa`, `1px solid #dee2e6` row borders, `#f0f0ff` hover, `max-height: 180px` + `overflow-y: auto`, `overflow-x: auto` with `white-space: nowrap`, uppercase 0.75rem headers, 6px radii, `#dc3545` delete hover.
|
||||
|
||||
### 6. `Main.jsx` — apply handler + placement
|
||||
|
||||
```jsx
|
||||
// Apply a saved combination - merge so non-lookup field values are preserved
|
||||
const handleApplyCombination = (data) => {
|
||||
setExtraData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
```
|
||||
|
||||
**Merge, not replace.** `data` only contains lookup codes (enforced server-side), so spreading over `prev` preserves every free-text/number/bool value the user already typed. Lookup fields absent from the combination keep their current value rather than being cleared — the safer default, never silently destroying input.
|
||||
|
||||
Placement inside the existing edit-form `.extra-section` at [Main.jsx:417-442](frontend/src/components/Main.jsx#L417-L442), directly above `.extra-fields`:
|
||||
|
||||
```jsx
|
||||
<h3>Additional Data</h3>
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombination}
|
||||
/>
|
||||
<div className="extra-fields">
|
||||
```
|
||||
|
||||
**Both forms.** The same table renders in the new-vehicle form and the edit form, each paired with its own state via a sibling handler:
|
||||
|
||||
```jsx
|
||||
const handleApplyCombinationNew = (data) => {
|
||||
setNewExtraData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour | Action |
|
||||
|---|---|---|
|
||||
| Entry **deactivated** after being saved in a combination | `getDisplayValue` searches all entries, so the label still renders. Save fails loudly with `Entry ID N not found`, caught by the existing `handleSaveExtra` error path. | None. Deactivation is soft and reversible; hiding rows would make them vanish and reappear. |
|
||||
| Entry **hard-deleted** | Cell flips live to muted italic `#<id>` (SSE removes it from the cache). | Render the marker so the broken row is visible and deletable — hiding it would remove the only way to clean it up. |
|
||||
| **Nomenclature deleted** | Its column disappears from the table; the orphan key stays in `data`, unrendered. Clicking such a row would fail validation on save with `Unknown nomenclature code`. | None. Admin-rare, self-resolving on the next fresh save. A cleanup signal would be speculative machinery. |
|
||||
| **Validation failure on click-apply** | Impossible — clicking is local `setState` only, no network. Errors surface at save via the existing `.vehicle-error` banner. | None. Don't special-case; this is how every other extra-data error already behaves. |
|
||||
| **Concurrent identical saves** | Handled by `get_or_create` (§3). | Done. |
|
||||
| **Empty projection** | Two guards in the recorder + serializer rejects `{}`. | Done. |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Backend on 8002 via `run_server.bat`; frontend via `npm start` in `frontend/` (port 5172).
|
||||
|
||||
Preflight: confirm `frontend/.env` sets `REACT_APP_API_URL=http://localhost:8002` — [NomenclatureContext.jsx:3](frontend/src/contexts/NomenclatureContext.jsx#L3) defaults to `:8000`, so a wrong value makes SSE silently dead while REST still works through the proxy. Confirm `http://localhost:5172` is in `CORS_ALLOWED_ORIGINS`.
|
||||
|
||||
```bat
|
||||
cd backend && ..\.venv\Scripts\activate
|
||||
python manage.py makemigrations vehicles && python manage.py migrate
|
||||
python manage.py showmigrations vehicles :: expect [X] 0005_extracombination
|
||||
```
|
||||
|
||||
Needs ≥2 vehicle lookup nomenclatures with active entries (e.g. `CARGO`, `SPEDITOR`) and one `kind='field'` text nomenclature (e.g. `NOTE`) for step 5.
|
||||
|
||||
1. **Empty state** — select a vehicle; "Previously Used" shows *No saved combinations yet* with correct columns.
|
||||
2. **First save creates a row** — Cargo=Coal, Speditor=ACME → Save Extra Data. Row appears with no page reload.
|
||||
3. **Dedup** — same combination on a *different* vehicle → still exactly one row. Verify:
|
||||
```bat
|
||||
python manage.py shell -c "from vehicles.models import ExtraCombination; [print(c.id, c.data, c.use_count, c.last_used) for c in ExtraCombination.objects.all()]"
|
||||
```
|
||||
Expect `use_count=2`, fresh `last_used`.
|
||||
4. **Click-apply** — clear both dropdowns, click the row → both populate. Save → one row, `use_count=3`.
|
||||
5. **Merge preserves free-text** *(most important regression check)* — type text into `NOTE`, click a combination row. **The note must survive**, and `NOTE` must have no column in the table.
|
||||
6. **Ordering** — save a second distinct combination; it appears above. Re-save the first → it returns to the top.
|
||||
7. **Delete over SSE** — open a second browser tab first, then delete a row via `×`. It must disappear in **both** tabs (this is the path `NomenclatureContext` doesn't natively support, so most likely to break).
|
||||
8. **New-vehicle path** — create a vehicle with extra data via "+ New Vehicle" → row created (verifies `perform_create`).
|
||||
9. **Empty projection** — create a vehicle with only `NOTE` filled → **no new row**; confirm count unchanged in the shell.
|
||||
10. **Stale id** — hard-delete a referenced entry via the `…` overlay. Cell flips to `#<id>` live; clicking + saving shows the `Entry ID N not found` error; `×` removes the row.
|
||||
11. **Key-order independence** —
|
||||
```bat
|
||||
python manage.py shell -c "from vehicles.models import combination_hash; print(combination_hash({'A':1,'B':2}) == combination_hash({'B':2,'A':1}))"
|
||||
```
|
||||
Expect `True`.
|
||||
12. **API surface** — `GET /api/extra-combinations/` returns the paginated envelope ordered by `-last_used`; `POST` returns **405**.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
**Backend**
|
||||
- `backend/vehicles/models.py` — imports, `combination_hash()`, `ExtraCombination`
|
||||
- `backend/vehicles/migrations/0005_extracombination.py` — generated
|
||||
- `backend/api/serializers.py` — extract `validate_vehicle_extra_data()`, slim `VehicleExtraSerializer.validate_data`, add `ExtraCombinationSerializer`
|
||||
- `backend/api/views.py` — imports, `_record_extra_combination()`, 1 line each in `perform_create`/`perform_update`, `ExtraCombinationViewSet`
|
||||
- `backend/api/urls.py` — 1 router line
|
||||
|
||||
**Frontend**
|
||||
- `frontend/src/components/ExtraCombinationsTable.jsx` — new
|
||||
- `frontend/src/components/ExtraCombinationsTable.css` — new
|
||||
- `frontend/src/components/Main.jsx` — import, `handleApplyCombination` + `handleApplyCombinationNew`, JSX insert in both forms
|
||||
- `frontend/src/contexts/NomenclatureContext.jsx` — 1 token at line 362
|
||||
|
||||
Untouched: `vehicles/views.py`, `vehicles/serializers.py`, `vehicles/urls.py` (dead code), `nomenclatures/`, `Main.css`.
|
||||
@@ -0,0 +1,451 @@
|
||||
# Finalize-on-print, History view, and unified report preview modal
|
||||
|
||||
## Context
|
||||
|
||||
Today, the moment a vehicle's second weighing lands, the backend silently
|
||||
computes `net` and assigns a `doc_number` (`_maybe_finalize`,
|
||||
[api/views.py:134-141](backend/api/views.py#L134-L141)) — but nothing in the
|
||||
UI reflects this as an event. The vehicle just sits in the active list
|
||||
forever, indistinguishable from an in-progress one except that its action
|
||||
column now shows Print/Design instead of Measure.
|
||||
|
||||
The user's actual workflow (matching their existing Delphi weighing software,
|
||||
and the problem it has that they want to avoid repeating): once a truck is
|
||||
weighed twice, the operator opens a report preview, prints or downloads the
|
||||
notice, and the truck should then leave the active list — because it's done,
|
||||
and a busy station's list should only show trucks still being processed. If a
|
||||
print physically fails (paper jam, printer offline — a real, frequent
|
||||
occurrence per the user, and one the browser's `window.print()` can never
|
||||
report back on, confirmed via `afterprint`'s fundamental limitation), the
|
||||
operator needs to get back to that vehicle's report without digging through a
|
||||
separate/hidden screen. A **History** view listing finalized vehicles, with
|
||||
the same print/download controls available there, is exactly that recovery
|
||||
path — and it becomes the *only* path back once a vehicle leaves the active
|
||||
list, so it must be trivial to reach, not buried.
|
||||
|
||||
This session's exploration surfaced two things that must be fixed as part of
|
||||
this work, not deferred:
|
||||
|
||||
1. **`driver_name` is dead on arrival.** The live vehicle API is
|
||||
`api/views.py` + `api/serializers.py` (registered in
|
||||
`api/urls.py`) — NOT `vehicles/views.py`/`vehicles/serializers.py`, which
|
||||
are orphaned (not routed) and contain the `driver_name`-required work from
|
||||
the previous session. `driver_name` is missing entirely from
|
||||
`VehicleSerializer.Meta.fields` in the real, live
|
||||
`api/serializers.py:165`. The frontend's create-vehicle form already sends
|
||||
`driver_name`, but the live backend silently drops it on the floor.
|
||||
2. **The frontend never removes anything from `vehicles`, and SSE has no
|
||||
working delete path.** `NomenclatureContext.handleSSEUpdate`
|
||||
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432))
|
||||
only handles `insert`/`update`; a `delete`/`archive` operation is silently
|
||||
dropped even though the backend's `operation_map` already produces
|
||||
`'delete'` for a `'deleted'` action. Any "vehicle leaves the list in real
|
||||
time, on every open tab" behavior needs this added — it is not
|
||||
pre-existing plumbing we can just call.
|
||||
|
||||
## Decisions made this session (do not re-litigate)
|
||||
|
||||
- **driver_name**: nullable at the DB level (already migrated), **required on
|
||||
create** at the serializer level — fix belongs in `api/serializers.py`
|
||||
(the live one), same validation approach already proven correct in the
|
||||
orphaned `vehicles/serializers.py` (object-level `validate()`, gated on
|
||||
`self.instance is None`, because a per-field `validate_driver_name` hook
|
||||
was tested and found to skip validation when the key is omitted entirely).
|
||||
- **When a vehicle leaves the active list**: on the operator's explicit
|
||||
**Print or Download PDF click** inside the new preview modal — not
|
||||
automatically the moment `net`/`doc_number` are assigned server-side. The
|
||||
user was explicit: "when press it - the truck is given a document number,
|
||||
disappears from list, the preview opens with print/download buttons."
|
||||
Concretely: the modal opens as soon as Print Notice is clicked (same
|
||||
trigger as today), and the archive+doc_number-visible transition happens
|
||||
when the operator acts inside that modal (Print or Download PDF), not on
|
||||
the initial open. Opening the modal must not, by itself, remove the truck —
|
||||
an operator who opens it and then closes it without printing must find the
|
||||
truck still there, still able to re-measure if needed.
|
||||
- **Preview modal layout** (replaces `ReportPreviewModal`'s current bare
|
||||
preview-only body): top bar with a matrix/A4 kind switcher on the left and
|
||||
Download PDF on the right; body renders the selected kind's preview; footer
|
||||
bar with centered Print and Close buttons. **The switcher is real, not a
|
||||
placeholder**: `notice_report` can have two actual saved `Report` rows
|
||||
(one `kind='matrix'`, one `kind='a4'`) side by side — confirmed the model
|
||||
already allows this (`name` has no uniqueness constraint) — and the
|
||||
switcher toggles between whichever of the two the operator has designed.
|
||||
If only one kind has been saved, the other option shows a disabled/empty
|
||||
state pointing at Design Notice for that kind. Default kind shown on open =
|
||||
the kind that was open/used last, or the first one that exists.
|
||||
- **Download PDF: REVERSED from an earlier draft of this plan** — no
|
||||
server-side rendering. Originally scoped as Playwright (Python) driving
|
||||
headless Chromium; abandoned once an existing plan
|
||||
([a4-report-designer.md](.claude/plans/a4-report-designer.md), §"Why
|
||||
Puppeteer is deferred (not rejected)") surfaced two concrete costs specific
|
||||
to this deployment that the earlier draft hadn't weighed:
|
||||
1. **Fonts.** A4 notices print with whatever font is installed on the
|
||||
*operator's station* — that's the whole point of using
|
||||
`window.print()`/`@page` CSS instead of a PDF library (see that same
|
||||
plan's "Why browser printing, not a PDF library"). Server-side
|
||||
rendering uses the *server's* fonts instead. At this project's actual
|
||||
reference deployment (one server, ten stations, per
|
||||
[[deployment-windows-installer]]), that's the wrong machine — a
|
||||
server-rendered PDF could visibly mismatch what prints at the station,
|
||||
forever, unless fonts are duplicated and kept in sync server-side.
|
||||
2. **Puppeteer/Playwright earns its cost when a PDF must be produced with
|
||||
no human in the loop** (auto-archiving, emailed tickets, batch jobs).
|
||||
Download PDF has an operator clicking a button and waiting — exactly
|
||||
the case `window.print()` → "Save as PDF" already covers, today, with
|
||||
zero new services and zero font-mismatch risk.
|
||||
**Decided approach**: Download PDF triggers the SAME `window.print()` flow
|
||||
already used for Print (`printA4Report` for a4, the new print-root-append
|
||||
flow from Phase C for matrix), just labelled/framed toward the "Save as
|
||||
PDF" destination rather than physical printing. No Playwright, no
|
||||
Puppeteer, no new backend endpoint, no offline-installer packaging cost.
|
||||
This also means Phase D as originally scoped (a new PDF microservice) is
|
||||
**not needed** — see the rewritten Architecture section below.
|
||||
- **Print button** in the modal (and Download PDF, per the reversal above)
|
||||
both use today's `window.print()`/`printA4Report()` flow for A4, and the
|
||||
Phase C print-root-append flow for matrix. No further backend work.
|
||||
- **History view**: a new page/route, reachable from the main nav (not
|
||||
hidden), listing finalized vehicles (those with `doc_number` set) — most
|
||||
recent first. Each row opens the same unified preview modal used from the
|
||||
main weighing screen, so reprint/redownload is the identical component and
|
||||
code path, not a second implementation.
|
||||
- **Nothing is ever deleted.** Explicit, standing rule for this feature and
|
||||
beyond: a vehicle record is never destroyed. There are three real states —
|
||||
**in progress** (active list), **completed** (History, has `doc_number`),
|
||||
and **voided** (History, flagged but still fully visible/queryable) — and
|
||||
moving between them is always a flag/field change, never a `DELETE`. This
|
||||
also means the orphaned `vehicles/views.py`'s `perform_destroy`
|
||||
([confirmed at api/views.py's pattern](backend/api/views.py) — check
|
||||
whether `VehicleViewSet` exposes a working DELETE at all; if it does, it is
|
||||
now out of step with this rule and should be disabled, flagged to the user
|
||||
rather than silently left reachable).
|
||||
- **Void ("сторно"/reversal)**: an operator-triggered action available **only
|
||||
from History, only on an already-completed record** — not on an
|
||||
in-progress vehicle, and not a bulk/automatic thing. Marks a completed
|
||||
weighing as voided when it turns out to be wrong (wrong truck, bad
|
||||
measurement, mistake caught after printing) — the record, its doc_number,
|
||||
and its full weighing history stay intact and visible in History,
|
||||
permanently flagged, distinguishable from a normal completed record at a
|
||||
glance (e.g. a visual badge, per how `.vehicle-status--*` already
|
||||
distinguishes states in the hero bar,
|
||||
[Main.css:189-203](frontend/src/components/Main.css#L189-L203)).
|
||||
- **`vehicle_number` uniqueness must change.** Today it's a plain
|
||||
`unique=True` column ([vehicles/models.py:11](backend/vehicles/models.py#L11))
|
||||
— global, forever. Combined with "nothing is ever deleted," this breaks the
|
||||
very next time a real truck (plate reused constantly) returns to the scale
|
||||
after its first weighing completes: creating the new active record would
|
||||
fail on a uniqueness violation. Decided fix: **unique only among
|
||||
in-progress vehicles** — a truck can't be on the scale twice at once, but
|
||||
its plate can appear any number of times across History. Implemented as a
|
||||
Django partial `UniqueConstraint`, not a plain column constraint:
|
||||
```python
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['vehicle_number'],
|
||||
condition=models.Q(doc_number__isnull=True),
|
||||
name='unique_active_vehicle_number',
|
||||
),
|
||||
]
|
||||
```
|
||||
(Django 6.0, confirmed installed — `condition=` on `UniqueConstraint` has
|
||||
been supported since Django 2.2, compiles to a PostgreSQL partial unique
|
||||
index, which this project's Postgres backend fully supports.) Requires
|
||||
dropping the existing `unique=True` on the field itself and adding this
|
||||
constraint instead — a real migration, not a no-op. Confirm during
|
||||
implementation whether "in-progress" should be defined as
|
||||
`doc_number__isnull=True` alone, or also exclude voided-but-somehow-
|
||||
doc_number-less edge cases — should not arise if void is only ever
|
||||
triggered on already-completed (doc_number-bearing) records, per the
|
||||
Void scope decision above, but worth a sanity check against real data.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
**1. Fix the live serializer** — `backend/api/serializers.py`:
|
||||
- Add `'driver_name'` to `VehicleSerializer.Meta.fields` (currently missing
|
||||
at line 165).
|
||||
- Add the required-on-create `validate()` override, ported from the pattern
|
||||
already proven in the orphaned `vehicles/serializers.py`:
|
||||
```python
|
||||
def validate(self, attrs):
|
||||
if self.instance is None and not attrs.get('driver_name'):
|
||||
raise serializers.ValidationError({'driver_name': 'This field is required.'})
|
||||
return attrs
|
||||
```
|
||||
(If `VehicleSerializer` already defines a `validate()`, merge into it
|
||||
rather than adding a second one.)
|
||||
- Leave the orphaned `vehicles/` app alone — it's unrouted dead code, not
|
||||
this task's problem to clean up (flag to the user separately; don't delete
|
||||
without being asked, per project convention).
|
||||
|
||||
**2. No PDF endpoint needed — REVERSED, see the Download PDF decision above.**
|
||||
No new backend endpoint, no Playwright, no server-side rendering. Phase D on
|
||||
the backend side is a no-op; everything below is frontend-only.
|
||||
|
||||
**3. Print-intent signal for both the modal's Print AND Download PDF
|
||||
buttons**: both now trigger the *existing* client flow
|
||||
(`window.print()`/`printA4Report` for a4, the Phase C print-root-append flow
|
||||
for matrix — Download PDF is the same call, just aimed at the "Save as PDF"
|
||||
destination the operator picks in that same dialog), which the backend can't
|
||||
observe directly.
|
||||
Add a lightweight endpoint, e.g. `POST /api/vehicles/{id}/mark-notice-printed/`,
|
||||
called by the frontend right when the operator clicks Print in the modal (not
|
||||
waiting on `afterprint`, since that signal is untrustworthy per this
|
||||
session's earlier discussion) — its only job is to broadcast the SSE removal
|
||||
event (below). It does not need to change any DB field; `doc_number` already
|
||||
being set is the source of truth for "this vehicle belongs in History, not
|
||||
the active list." This keeps "print succeeded" and "should leave the active
|
||||
list" cleanly decoupled, exactly as discussed: a failed physical print still
|
||||
correctly moves the truck to History (where it's one click to reprint), it
|
||||
does not silently strand it in a state nobody can find.
|
||||
|
||||
**4. SSE removal broadcast**: wherever the "moved to history" signal fires
|
||||
(the `mark-notice-printed` endpoint, and/or the new PDF endpoint on
|
||||
successful generation), call
|
||||
`sse_broadcast_update('vehicle', 'archived', serializer.data)` — a new,
|
||||
distinct action string (not reusing `'deleted'`, since the vehicle record
|
||||
itself isn't deleted, only its presence in the *active* list changes) — see
|
||||
frontend section for how this action is consumed. Using
|
||||
[sse_broadcast_update](backend/scalesapp/sse.py#L26-L58), same call shape
|
||||
already used by `perform_create`/`perform_update`.
|
||||
|
||||
**5. Filter the active-list endpoint** (optional but recommended for
|
||||
correctness/scale): today `VehicleViewSet.queryset` returns all vehicles
|
||||
unconditionally. Once History exists, the main screen's vehicle list should
|
||||
probably query only non-finalized vehicles server-side too (e.g.
|
||||
`?doc_number__isnull=true`), not rely purely on client-side SSE-driven
|
||||
removal — SSE gives real-time updates for tabs already open, but a **fresh
|
||||
page load** should not have to fetch every historical vehicle ever weighed
|
||||
just to filter them out client-side. Add a query param the frontend's main
|
||||
list fetch already passes (`NomenclatureContext`'s vehicle endpoint config,
|
||||
[NomenclatureContext.jsx:9](frontend/src/contexts/NomenclatureContext.jsx#L9)),
|
||||
and a separate History fetch that explicitly requests
|
||||
`?doc_number__isnull=false`, ordered newest-first (`-net_date` or similar).
|
||||
|
||||
**6. `is_void` field + partial-uniqueness migration** — the `Vehicle` model
|
||||
itself lives at `backend/vehicles/models.py` regardless of which app's
|
||||
views/serializers are live (there is only ONE `Vehicle` model — `vehicles/`
|
||||
being "orphaned" refers only to that app's OWN `views.py`/`serializers.py`,
|
||||
not the model, which `api/views.py` imports and uses directly). So this
|
||||
migration correctly targets `backend/vehicles/models.py`, but the
|
||||
`@action`/serializer changes below belong in `api/views.py`/`api/serializers.py`
|
||||
(the live ones), matching how `driver_name` and everything else in this plan
|
||||
is split: model in `vehicles/`, live API surface in `api/`.
|
||||
- Add `is_void = models.BooleanField(default=False)`, plus
|
||||
`void_date`/`void_user`/`void_reason` following the same
|
||||
date/user/note provenance pattern already used for `tare`/`gross`/manual
|
||||
entries elsewhere on this model (e.g. `tare_manual_user`,
|
||||
`tare_manual_date`, `tare_manual_note`,
|
||||
[vehicles/models.py:19-22](backend/vehicles/models.py#L19-L22)) — reuse
|
||||
that established shape rather than inventing a different one for void.
|
||||
- Replace `vehicle_number = models.CharField(max_length=15, unique=True)`
|
||||
with the plain field (drop `unique=True`) plus the partial
|
||||
`UniqueConstraint` from the decision above, in one migration.
|
||||
- New `@action` on `VehicleViewSet`, e.g.
|
||||
`POST /api/vehicles/{id}/void/` (mirroring `measure`'s permission-gated
|
||||
pattern) — sets `is_void`, `void_date`, `void_user`, accepts a required
|
||||
`reason` in the request body (store it; an operator voiding a printed
|
||||
document should always leave a reason, same spirit as
|
||||
`gross_manual_note`/`tare_manual_note` already require context for a
|
||||
manual override). Rejects (400) if `doc_number` is not set — void only
|
||||
applies to completed records, per the scope decision above. This does NOT
|
||||
set `doc_number` to null or touch `net`/`tare`/`gross` — everything about
|
||||
the original weighing stays exactly as it was, only `is_void` changes.
|
||||
Broadcasts `sse_broadcast_update('vehicle', 'voided', serializer.data)` so
|
||||
any open History view updates live too.
|
||||
- `VehicleSerializer` needs `is_void`/`void_date`/`void_reason` added to
|
||||
`Meta.fields` (read-only except through the dedicated void action).
|
||||
|
||||
### Frontend
|
||||
|
||||
**1. `NomenclatureContext.jsx`** — add a `delete`/`archive` branch to
|
||||
`handleSSEUpdate`'s switch
|
||||
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432)),
|
||||
handling the new `'archived'` action (mapped through `operation_map` on the
|
||||
backend, or handled as a raw fallback string if not added to that map — check
|
||||
which is simpler when implementing) by removing the vehicle from the
|
||||
in-memory `vehicles` array. This is the one piece of "real-time removal"
|
||||
plumbing that doesn't exist anywhere today and is the crux of making
|
||||
multi-station "truck vanishes everywhere at once" actually work.
|
||||
|
||||
**2. Unified `ReportPreviewModal`** — restructure
|
||||
[ReportPreviewModal.jsx](frontend/src/components/ReportPreviewModal.jsx) (and
|
||||
its `.css`) into three regions instead of the current bare header+body:
|
||||
- **Top bar**: left = matrix/A4 toggle, switching between two genuinely
|
||||
separate stored `Report` rows sharing the same `name='notice_report'` but
|
||||
different `kind`. Confirmed this needs no schema change: `Report.name` has
|
||||
**no uniqueness constraint**
|
||||
([api/models.py:45-75](backend/api/models.py#L45-L75)), so a matrix row and
|
||||
an a4 row can already coexist under the same name — nothing stops it today.
|
||||
The gap is entirely on the fetch/lookup side:
|
||||
- `fetchNoticeReport()` ([Main.jsx:357-360](frontend/src/components/Main.jsx#L357-L360))
|
||||
currently does `.find(r => r.name === 'notice_report')`, returning
|
||||
whichever row the API lists first — it has no way to ask for "the matrix
|
||||
one" vs "the a4 one." Change it to accept a `kind` argument and filter
|
||||
accordingly (or add a backend query param, `?name=notice_report&kind=a4`,
|
||||
since `ReportViewSet` already has `filterset_fields = ['name', 'kind']`
|
||||
— the backend filter already exists, this is a client-side fix only).
|
||||
- The modal fetches BOTH kind-variants on open (two lookups, or one list
|
||||
call filtered by name only, then split client-side by `kind`), so the
|
||||
switcher can toggle without a re-fetch. If one kind doesn't exist yet
|
||||
(operator never designed an A4 version), that switcher option is
|
||||
disabled/shows an empty state with a link to Design Notice for that kind.
|
||||
- `handleDesignNotice`/`handleDesignAs`
|
||||
([Main.jsx:403-449](frontend/src/components/Main.jsx#L403-L449)) already
|
||||
let an operator explicitly open either editor regardless of what's saved
|
||||
(`handleDesignAs('matrix'|'a4')`) — this already supports creating the
|
||||
*other* kind's report under the same name; the Report Editor's own save
|
||||
path (`handleSave`,
|
||||
[ReportEditor.jsx:276-297](frontend/src/components/ReportEditor/ReportEditor.jsx#L276-L297))
|
||||
does a plain `POST` when `reportId` is unset, so saving a second,
|
||||
different-kind `notice_report` is already a supported write path with no
|
||||
backend changes needed there either.
|
||||
Right = Download PDF button — **REVERSED, see the top-level decision
|
||||
above**: no backend endpoint, no blob download. Triggers the same
|
||||
`window.print()`/`printA4Report` (a4) or print-root-append (matrix) flow
|
||||
as the Print button, so the operator reaches PDF via "Save as PDF" in that
|
||||
same dialog, using the station's own installed fonts. (Phase C shipped
|
||||
this button as a disabled placeholder before this reversal was decided —
|
||||
Phase D's remaining frontend work is wiring it to the real print flow
|
||||
instead of leaving it disabled, plus the `MatrixPrintDocument` work above.)
|
||||
- **Body**: existing `EditorCanvas`/`DataProvider`/`BandProvider` preview for
|
||||
matrix; for A4, mount `PrintDocument` (confirmed stateless/reusable,
|
||||
[PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
|
||||
scaled into the modal body instead of only appending it to `document.body`
|
||||
for `window.print()` — needs a scaled/contained rendering mode, not the
|
||||
full-bleed print-root mounting `printReport.jsx` uses today.
|
||||
- **Footer bar**: centered Print + Close. Print triggers the kind-appropriate
|
||||
existing print flow (`printA4Report` for a4; for matrix, there is currently
|
||||
no equivalent "just print this" path — `ReportPreviewModal` today is
|
||||
preview-only with no print button at all, so a matrix print path needs to
|
||||
be added, likely also via `window.print()` on the rendered preview DOM, or
|
||||
reusing the same print-root-append pattern `printReport.jsx` established).
|
||||
Then calls the new `mark-notice-printed` endpoint.
|
||||
- Replace `Main.jsx`'s current branching logic
|
||||
([handlePrintNotice, lines 375-398](frontend/src/components/Main.jsx#L375-L398))
|
||||
so BOTH kinds open this one modal (today only matrix does; a4 skips the
|
||||
modal entirely and goes straight to `window.print()`) — this is the biggest
|
||||
behavioral change to `handlePrintNotice`: it becomes "always open the
|
||||
modal," full stop, with the modal itself deciding what Print/Download do
|
||||
based on the loaded report's kind.
|
||||
|
||||
**3. New History page** — new route (e.g. `/history`) added alongside the
|
||||
existing routes in
|
||||
[App.jsx](frontend/src/App.jsx) (inside the same provider nest, following the
|
||||
existing route-gating pattern used for admin-only pages). Fetches finalized
|
||||
vehicles (`?doc_number__isnull=false`, newest first) independently of the
|
||||
live `vehicles` array (History should NOT depend on `NomenclatureContext`'s
|
||||
in-memory list, since that list is specifically the *active* set now).
|
||||
List each with vehicle number, doc number, net weight, date, and a
|
||||
completed/voided badge (`is_void`); clicking a row opens the same unified
|
||||
`ReportPreviewModal`, passing that vehicle's data exactly as `Main.jsx` does
|
||||
today via `vehicleReportData`
|
||||
([Main.jsx:313-334](frontend/src/components/Main.jsx#L313-L334) — this
|
||||
transform logic is reusable, likely worth extracting to a shared helper
|
||||
rather than duplicating in the new History component). Print/Download PDF
|
||||
stay available on a voided row too — voiding never removes the ability to
|
||||
see or reproduce the original document, it only flags that the weighing
|
||||
itself shouldn't be treated as valid.
|
||||
- **Void button**: on a completed (non-voided) row, a distinct action from
|
||||
opening the preview — requires a reason (matches the backend's required
|
||||
`reason` field), then calls the new `/void/` endpoint. On success, the row
|
||||
updates in place to show the voided badge; it does not disappear or move
|
||||
anywhere (History is a flat list of everything that ever completed,
|
||||
voided or not). Confirm with a dialog before voiding — this is meant to be
|
||||
rare and deliberate, matching the "сторно/reversal" concept's own
|
||||
seriousness (it's correcting the record, not casually undoable further).
|
||||
- A nav entry for History (in `Header.jsx`, alongside whatever nav already
|
||||
exists) — must be easy to find, per the whole point of this feature being
|
||||
"don't bury the recovery path."
|
||||
|
||||
## Open questions to resolve during implementation
|
||||
|
||||
Phase D, resolved: no Playwright/Puppeteer, no server-side PDF endpoint (see
|
||||
the Download PDF decision above). The matrix print path question is already
|
||||
resolved too — Phase C built it (print-root-append, following
|
||||
`printReport.jsx`'s pattern, confirmed working).
|
||||
|
||||
**Remaining open item, genuinely new scope surfaced while designing Phase D**:
|
||||
|
||||
1. **Matrix has no `PrintDocument`-equivalent.** A4's `PrintDocument.jsx`
|
||||
([print/PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
|
||||
is a small (107-line), stateless, non-interactive component that reads
|
||||
`layout()`'s pure `pages[]` output and renders plain positioned HTML — no
|
||||
hooks, no drag/select/delete callbacks. Its own doc comment already
|
||||
anticipated exactly this kind of reuse: *"DELIBERATELY DUMB... that is
|
||||
what lets a future Puppeteer service consume this exact markup."* Matrix
|
||||
has no equivalent: its preview goes through `EditorCanvas`
|
||||
([EditorCanvas.jsx](frontend/src/components/ReportEditor/EditorCanvas.jsx),
|
||||
682 lines), the SAME interactive editor component used for actual
|
||||
editing, gated by a `previewMode` prop — confirmed by reading it that
|
||||
`previewMode` is a flag threaded through the interactive tree, not a
|
||||
separate rendering path; every element still gets full
|
||||
`onSelect`/`onUpdate`/`onDelete`/`onDragStart` callback props even in
|
||||
preview mode. Phase C's matrix print-root flow works today only because
|
||||
it reuses this same `EditorCanvas`-in-`previewMode` machinery — it works,
|
||||
but it's borrowing an editor component for a job a pure renderer should
|
||||
do.
|
||||
- **Decided**: build a `MatrixPrintDocument`-equivalent as part of this
|
||||
phase — a pure, non-interactive component mirroring what
|
||||
`PrintDocument.jsx` already does for A4, reading matrix `elements` +
|
||||
resolved band/data output and emitting plain markup. This is real,
|
||||
bounded new work (not a redesign of anything existing), and it's a
|
||||
prerequisite for the matrix print/preview path to reach the same
|
||||
quality bar A4 already has — it does NOT unlock server-side PDF
|
||||
generation (that's still explicitly out of scope, per the reversal
|
||||
above); it's purely about correctness/simplicity of the existing
|
||||
client-side matrix print flow, replacing "borrow the editor" with "use
|
||||
a real preview renderer," same shape as A4.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Create a vehicle without `driver_name` via the API directly (curl/Postman)
|
||||
— confirm it's now rejected with a 400 and the field-specific error,
|
||||
where today it would silently succeed with `driver_name` dropped.
|
||||
2. Weigh a vehicle twice (tare + gross) in the browser — confirm it still
|
||||
shows in the active list (finalization alone must not remove it).
|
||||
3. Click Print Notice — confirm the unified modal opens (for BOTH a matrix-
|
||||
and an a4-kind `notice_report`, test each), with top bar (kind
|
||||
switcher + Download PDF), body preview, footer (Print + Close centered).
|
||||
4. Click Close without printing — confirm the vehicle is still in the active
|
||||
list (opening the modal alone must not archive).
|
||||
5. Click Print — confirm `window.print()`/`printA4Report` still works exactly
|
||||
as before, AND the vehicle disappears from the active list within ~1s on
|
||||
the SAME tab and on a SECOND open tab (real-time SSE removal across
|
||||
stations).
|
||||
6. Click Download PDF instead — confirm it opens the same `window.print()`
|
||||
dialog as Print (with "Save as PDF" available as a destination, per the
|
||||
reversed decision — no direct blob download, no bypass of the OS dialog),
|
||||
and the vehicle is similarly removed from the active list once triggered.
|
||||
7. Open History — confirm the just-archived vehicle appears, newest first,
|
||||
with correct doc_number/net/date.
|
||||
8. Click that History row — confirm the same modal opens and Print/Download
|
||||
both work again (the reprint path this whole feature exists to guarantee).
|
||||
9. Confirm a vehicle still mid-weighing (no `gross` yet) never appears in
|
||||
History.
|
||||
10. **Reuse a plate**: complete a weighing for `B7898TP`, confirm it archives
|
||||
to History, then create a brand-new active vehicle also named `B7898TP`
|
||||
— confirm this succeeds (no uniqueness error), proving the partial
|
||||
constraint is scoped correctly. Then try creating a SECOND simultaneous
|
||||
active `B7898TP` while the first is still in progress — confirm this is
|
||||
rejected (proving uniqueness is still enforced among active records).
|
||||
11. From History, void a completed record without providing a reason —
|
||||
confirm it's rejected. Void it with a reason — confirm the row updates
|
||||
in place with a voided badge, doc_number/net/tare/gross are unchanged,
|
||||
and Print/Download still work on it afterward.
|
||||
12. Attempt to void an active (non-completed) vehicle directly via the API —
|
||||
confirm it's rejected (void is completed-records-only, per scope).
|
||||
13. Confirm nothing in this feature ever issues a DELETE against a vehicle.
|
||||
**Done, not just checked**: `VehicleViewSet.destroy()` (the LIVE one in
|
||||
`api/views.py` — found reachable via `IsAuthenticated` alone, worse than
|
||||
the orphaned `vehicles/views.py` this item originally worried about) now
|
||||
unconditionally returns `405` pointing at `/void/` instead. Verify with a
|
||||
direct `DELETE /api/vehicles/{id}/` call that it's rejected and the
|
||||
vehicle still exists afterward.
|
||||
14. **Matrix print quality**: with the new `MatrixPrintDocument`, print (and
|
||||
Download-as-PDF-via-print) a matrix `notice_report` — confirm the output
|
||||
matches what the old `EditorCanvas`-in-`previewMode` flow produced
|
||||
(no regression), and confirm the print/preview path no longer carries
|
||||
editor-only affordances (no residual selection/drag artifacts, since a
|
||||
non-interactive component can't have any).
|
||||
@@ -0,0 +1,185 @@
|
||||
# Licensing: Server-Side Node-Locked with Station Limit
|
||||
|
||||
**Status: BUILT and verified, 2026-09-17.** This document is the record of what exists, not a proposal.
|
||||
|
||||
## Context
|
||||
|
||||
Customers buy ScalesApp for a fixed number of weighbridge stations. Without enforcement, nothing stops a customer copying the installer onto more machines. Sites are **air-gapped** (no internet on the scales server), so any online activation or phone-home scheme is impossible.
|
||||
|
||||
This licences the **server**, enforcing a maximum number of concurrently connected stations. Bridges themselves stay unlicensed.
|
||||
|
||||
**Decisions made with the user:**
|
||||
- **Licence the server, not each bridge** — one fingerprint, one key per customer, instead of N of each.
|
||||
- **Count = concurrently connected bridges.** Licence says 5; a 6th is declined. If one of the 5 stops, its slot frees automatically.
|
||||
- **Enforcement is per-station**: the 6th is refused, the working 5 are never interrupted.
|
||||
- **Tolerant fingerprint + 14-day grace period** on hardware change.
|
||||
- **Perpetual** — no expiry date (the signed payload carries an optional `expires` field so subscriptions can be added later without changing the format).
|
||||
- Slot refresh every 30s, released after 2 minutes (4 missed refreshes).
|
||||
|
||||
---
|
||||
|
||||
## Threat model — stated plainly
|
||||
|
||||
Client-side licensing **can always be defeated** by someone determined; the server is a Python app and bytecode is inspectable. The goal is not "unbreakable":
|
||||
|
||||
- Honest customers stay honest; the limit is a clear contractual line
|
||||
- Casual copying (operator copies the folder to 3 more PCs) is stopped
|
||||
- A determined pirate with a cracker is **not** stopped
|
||||
|
||||
For B2B sold to a port with a support contract, that is the right level. Do not invest in obfuscation — it raises effort, never to infinity, and costs debuggability.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
### Signing, not a shared secret
|
||||
|
||||
The critical design point: **the application only ever verifies; it can never generate.**
|
||||
|
||||
1. Server computes a **machine fingerprint** from stable hardware IDs
|
||||
2. Customer sends that string (email, phone, photo — it is short)
|
||||
3. Vendor runs `tools/license_tool.py sign`, which signs `{customer, stations, components}` with the **Ed25519 private key**
|
||||
4. Customer pastes the key back; the server verifies with the **public key** compiled into `api/licensing.py`
|
||||
|
||||
A shared-secret or same-algorithm-both-ways scheme would be worthless — anyone who unpacks the app could mint keys. With signatures, forging requires the private key, which never leaves the vendor's machine.
|
||||
|
||||
Blob format: `base64("<payload json>.<base64 signature>")`.
|
||||
|
||||
### Tolerant fingerprint
|
||||
|
||||
Four components are collected and hashed **separately** (not combined), so partial matching is possible: motherboard serial, disk serial, first physical MAC, machine GUID.
|
||||
|
||||
Validation passes when **at least 3 of 4** match (`MIN_COMPONENT_MATCHES`). A replaced disk or NIC keeps working; a wholly different PC fails.
|
||||
|
||||
**Windows uses PowerShell CIM, not `wmic`.** This matters: `wmic` is deprecated and **has been removed from recent Windows 11 builds**, where it fails silently and yields nothing. On this development machine the original `wmic` approach found only **1 of 4** components, which would have made every licence fail to validate. Each lookup falls back to `wmic` so older machines still work. Linux reads `/sys/class/dmi`, `lsblk`, `/sys/class/net` and `/etc/machine-id`.
|
||||
|
||||
Missing components return `None` rather than raising — a VM or restricted environment may not expose all four, and the 3-of-4 rule tolerates that.
|
||||
|
||||
### Grace period
|
||||
|
||||
If validation fails (fewer than 3 components match, or no licence at all), the server enters a **14-day grace period**: everything keeps working, a persistent non-dismissible banner appears for all users, and the admin licence screen explains it. After 14 days the limit drops to **1 station** (`UNLICENSED_STATIONS`).
|
||||
|
||||
Rationale: a licence check must never stop a weighbridge at 3am. Grace turns a hard outage into a support call.
|
||||
|
||||
Grace start is stored in the **database**, not a file — a file is trivially reset, and the DB is included in backups.
|
||||
|
||||
---
|
||||
|
||||
## Counting connected stations
|
||||
|
||||
The bridge **deliberately never talks to Django** — that independence is a design decision (see `bridge-is-transport-only` in project memory). Licensing does not change it.
|
||||
|
||||
**The browser reports instead.** It already talks to both. On load and every 30s it reads the bridge's `/status` and POSTs a heartbeat:
|
||||
|
||||
```
|
||||
POST /api/license/heartbeat/ {"station_key": "st-abc123", "com_port": "COM3", "bridge_version": "1.1.0"}
|
||||
-> 200 {"allowed": true, "stations_used": 3, "stations_licensed": 5}
|
||||
-> 403 {"allowed": false, "detail": "Licence limit reached (5 stations)"}
|
||||
```
|
||||
|
||||
`station_key` is a short stable hash of the bridge address plus its COM port, so **two browser tabs on the same station share one slot** rather than consuming two.
|
||||
|
||||
`StationSlot` rows are live if `last_seen` is within `SLOT_TIMEOUT_SECONDS` (120).
|
||||
|
||||
**A reconnecting station reclaims its own slot even at the limit** — matched on `station_key` in `_station_allowed()`. This is the single most important rule in the counting logic: without it, a station that blipped would be locked out by its own expiring ghost, which would look exactly like a licensing bug at the worst possible moment.
|
||||
|
||||
### What "declined" means
|
||||
|
||||
The 6th station's Measure button is disabled with the reason shown beneath it, and `POST /api/vehicles/{id}/measure/` returns 403 for that station key. Viewing and editing continue normally. The other five are entirely unaffected.
|
||||
|
||||
**The API check is the rule; the button state is only convenience.** Enforcement lives in `VehicleViewSet.measure`, not the UI.
|
||||
|
||||
---
|
||||
|
||||
## What was built
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `api/licensing.py` | `machine_components()`, `machine_fingerprint()`, `verify_license()`, `license_status()`, `effective_station_limit()`, `LICENSE_PUBLIC_KEY` |
|
||||
| `api/models.py` | `License` (single row, enforced in `save()`), `StationSlot` |
|
||||
| `api/views.py` | `LicenseView` (GET status / POST install), `LicenseHeartbeatView`, `_station_allowed()`, licence check in `measure` |
|
||||
| `api/urls.py` | `license/`, `license/heartbeat/` |
|
||||
| `api/migrations/0008_license_stationslot.py` | generated |
|
||||
| `requirements.txt` | `cryptography==50.0.1` added (PyJWT alone only does HMAC — a shared secret, i.e. the worthless scheme) |
|
||||
|
||||
### Vendor tooling — `tools/license_tool.py`
|
||||
|
||||
```bash
|
||||
python tools/license_tool.py keygen # once; refuses to overwrite without --force
|
||||
python tools/license_tool.py sign --customer "Port of Varna" --stations 5 \
|
||||
--components '{"board":"...","disk":"...","mac":"...","guid":"..."}'
|
||||
```
|
||||
|
||||
`sign` refuses fewer than 3 components, since such a licence could never validate on the customer machine.
|
||||
|
||||
### Frontend
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `contexts/LicenseContext.jsx` | 30s heartbeat, derives `station_key` from the bridge's `/status` |
|
||||
| `components/License/LicenseManager.jsx` + `.css` | Admin screen: state, fingerprint with copy buttons, paste box, connected-station table. 🔑 in header |
|
||||
| `components/Main.jsx` | Measure gated on `licenseAllowed`, `station_key` sent with both measure calls, grace banner |
|
||||
| `App.jsx` | `LicenseProvider`, `/license` route (admin only) |
|
||||
|
||||
Design note: `LicenseContext` **assumes allowed until told otherwise**, so a slow first heartbeat never blocks a working station. Non-403 failures (server down) leave the previous state alone — a licence check must not stop a working weighbridge.
|
||||
|
||||
---
|
||||
|
||||
## Security: the private key
|
||||
|
||||
**`tools/license_private.key`** — gitignored via `*license*.key`, generated 2026-09-17. Verified not tracked.
|
||||
|
||||
- **If it leaks**, every licence ever issued becomes forgeable
|
||||
- **It cannot be rotated** without reissuing to every customer, since the public key is compiled into shipped binaries
|
||||
- **If it is lost**, no new licences can ever be signed for existing installs
|
||||
|
||||
Back it up outside the repo. Never ship it. The public key in `api/licensing.py` is safe to commit and must be correct in the build before shipping.
|
||||
|
||||
---
|
||||
|
||||
## Verification (all passing)
|
||||
|
||||
### Crypto — 6 checks
|
||||
|
||||
1. Genuine licence verifies; customer and station count read back correctly
|
||||
2. **Tampering rejected** — editing `"stations":5` to `"stations":500` in a real signed blob breaks the signature. *This is the property the entire scheme rests on.*
|
||||
3. Licence for a different machine rejected (1 of 4 match, 3 required)
|
||||
4. **3-of-4 tolerance works** — one component changed (simulated disk replacement) still validates
|
||||
5. Empty, garbage and malformed blobs rejected cleanly with distinct messages
|
||||
6. Expired licence rejected
|
||||
|
||||
### End to end — 10 checks
|
||||
|
||||
1. No licence → grace period, 14 days, still usable
|
||||
2. Install a 5-station licence → customer and count correct
|
||||
3. Five stations connect → `used=5 licensed=5`
|
||||
4. **Sixth declined** → 403, "Licence limit reached (5 stations)"
|
||||
5. The five keep working (refresh succeeds)
|
||||
6. **Reconnect at the limit reclaims its own slot** → 200, not locked out
|
||||
7. Offline station frees its slot after timeout → sixth then admitted
|
||||
8. `measure` with an unregistered station key → 403
|
||||
9. `measure` from a licensed station → 200
|
||||
10. Grace expiry (backdated 15 days) → limit drops to 1
|
||||
|
||||
Frontend builds clean; no warnings in any licensing file.
|
||||
|
||||
---
|
||||
|
||||
## Operating notes
|
||||
|
||||
**Currently in grace mode** — no licence installed, so everything works and the banner shows. Install one from the 🔑 screen to test the licensed path.
|
||||
|
||||
**To issue a licence:** customer opens 🔑 → copies the component JSON from "Full details" → sends it → run `license_tool.py sign` → customer pastes the result into the install box.
|
||||
|
||||
The fingerprint shown large (e.g. `C261-A181-3D8D-AC94`) is for reading over the phone to confirm identity. The component JSON underneath is what `sign` actually needs. Both are one-way hashes, so they carry no identifying hardware detail.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Expiry / subscriptions** — perpetual only; the payload field exists so this can be added without reissuing the format
|
||||
- **Licensing individual bridges** — rejected as N× the administration
|
||||
- **Obfuscation / anti-tamper** — see the threat model
|
||||
- **Named Station entities** — slots are identified by bridge hash; a Station model was deliberately skipped
|
||||
@@ -0,0 +1,163 @@
|
||||
# Main page: 5-group weighing row + form restyle
|
||||
|
||||
## Context
|
||||
|
||||
The weighing detail panel doesn't match the layout the user wants (reference
|
||||
screenshot: a single row of five groups — CURRENT READING | TARE | GROSS | NET |
|
||||
actions). Three things differ today:
|
||||
|
||||
1. **Current Reading is a separate full-width block** (`.reading-display`) sitting
|
||||
*above* the card row, not part of it.
|
||||
2. **There is a redundant "Scale" card** duplicating the same live weight, which
|
||||
exists only to host the Measure / Manual entry buttons. The user's call:
|
||||
these two are the same thing — **merge into one card**.
|
||||
3. **Tare and Gross are all-or-nothing.** Today a lone first reading renders as a
|
||||
single neutral "First weighing" card, and Tare/Gross only appear once both
|
||||
exist. The user wants the five groups always visible, with the first reading
|
||||
shown plainly in its slot.
|
||||
|
||||
Result: a stable five-column row that never changes its column count.
|
||||
|
||||
### The one behaviour being deliberately traded away
|
||||
|
||||
[Main.jsx:604-605](frontend/src/components/Main.jsx#L604-L605) documents why the
|
||||
merged card exists: the backend parks a lone reading in `tare`, but a **lighter
|
||||
second reading moves it to `gross`**. So a single reading labelled "TARE" can
|
||||
later jump to the GROSS box. The user chose "show it plainly" — accepting that
|
||||
jump. `firstWeighing` (Main.jsx:290-309) becomes dead and is removed.
|
||||
|
||||
## Approach
|
||||
|
||||
### 1. Merge Current Reading + Scale into one card
|
||||
|
||||
Delete the standalone `.reading-display` block (Main.jsx:581-600) and the Scale
|
||||
card (647-677). One card takes their place as the row's first column, keeping the
|
||||
**richer** of the two behaviours — the reading-display value logic, which the
|
||||
Scale card lacked (disconnected state, `readingError` line):
|
||||
|
||||
```jsx
|
||||
<div className="weight-card weight-card--reading">
|
||||
<div className="weight-card-header">Current Reading</div>
|
||||
<div className={`weight-card-value weight-card-value--reading ${…same ternary as today's reading-value…}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')}
|
||||
</div>
|
||||
{isConnected && readingError && latestReading && (
|
||||
<div className="reading-error" title={latestReading.data}>{readingError}</div>
|
||||
)}
|
||||
{canMeasure && !isFinalized && (<>
|
||||
<button className="weight-set-btn" onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}>Measure</button>
|
||||
{!licenseAllowed && <div className="weight-license-block">{licenseDetail}</div>}
|
||||
{currentUser?.can_manually_measure && (
|
||||
<button className="weight-manual-btn" onClick={…}>Manual entry</button>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
```
|
||||
|
||||
Keep the `.reading-value--balanced/--unbalanced` class names on the value so the
|
||||
existing colour rules (Main.css:547-553) and cockpit glow keep applying
|
||||
unchanged. The `--reading` modifier carries the larger font that
|
||||
`.reading-value` had.
|
||||
|
||||
### 2. Tare / Gross / Net always render
|
||||
|
||||
Replace the ternary at Main.jsx:606-644 with three unconditional cards. Each
|
||||
shows `—` and no meta when its value is null:
|
||||
|
||||
- Tare — `selectedVehicle.tare`, `tare_date`, `tare_user_name`, `tare_manual`
|
||||
- Gross — same shape with `gross_*`
|
||||
- Net — `netWeight` (null until both exist)
|
||||
|
||||
Add modifier classes `weight-card--tare`, `--gross` (Net already has `--net`).
|
||||
**This is a prerequisite, not polish:** Main.css:576-578 states the cockpit rules
|
||||
use `:nth-child` precisely because these cards have no distinguishing class, and
|
||||
the column count is about to change. Those `:nth-child` selectors must be
|
||||
rewritten to the modifiers in the same pass or cockpit chrome lands on the wrong
|
||||
cards.
|
||||
|
||||
Net keeps Print Notice + the Design Notice split button inside it (unchanged
|
||||
markup, including the dropdown menu and backdrop). Gate them on `netWeight !==
|
||||
null` so an empty Net card shows no dead buttons.
|
||||
|
||||
### 3. Row becomes a grid
|
||||
|
||||
`.weighing-section` moves from `flex` + `flex-wrap` to a 5-column grid, following
|
||||
the ratio proven in the concept file
|
||||
([ScalesAppCyberpunk.css:94](frontend/src/components/concepts/ScalesAppCyberpunk.css#L94)):
|
||||
|
||||
```css
|
||||
.weighing-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
```
|
||||
|
||||
Four columns, because Print/Design stay inside Net rather than becoming a
|
||||
separate action column — the user kept them there. Reading gets the wider track.
|
||||
|
||||
Remove `.reading-display`'s `margin-bottom: 20px` block along with the element.
|
||||
Lift `.vehicle-detail { max-width: 700px }` (Main.css:115) — four readouts do not
|
||||
fit in 700px. The cockpit override at 851-853 that already does this then becomes
|
||||
redundant and should go.
|
||||
|
||||
Responsive, replacing the cockpit-only `@media (max-width:1150px)` rule:
|
||||
```css
|
||||
@media (max-width: 1150px) { .weighing-section { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 620px) { .weighing-section { grid-template-columns: 1fr; } }
|
||||
```
|
||||
These apply to **both** themes now, so drop the `[data-theme="cockpit"]` prefix
|
||||
from the wrap rules at 858-872.
|
||||
|
||||
### 4. Form restyle (styling only, no new capability)
|
||||
|
||||
Confirmed scope: the create form only. There is no edit form and none is being
|
||||
added. In Main.css:
|
||||
|
||||
- `.extra-field input/select` — set explicit `background: var(--surface)` and
|
||||
`color: var(--text)`. They currently inherit UA defaults and survive cockpit
|
||||
only by accident of `color-scheme: dark`.
|
||||
- `.extra-fields` — add `@media (max-width: 768px) { grid-template-columns: 1fr }`;
|
||||
it is a hard `1fr 1fr` at every width today.
|
||||
- `.vehicle-form-actions` — add `justify-content: flex-end` to match the
|
||||
`.manual-actions` precedent (Main.css:539+).
|
||||
|
||||
## Critical files
|
||||
|
||||
- [frontend/src/components/Main.jsx](frontend/src/components/Main.jsx) — lines
|
||||
581-741 replaced; `firstWeighing` memo (290-309) deleted
|
||||
- [frontend/src/components/Main.css](frontend/src/components/Main.css) —
|
||||
`.reading-display` block removed, `.weighing-section` → grid, `max-width` cap
|
||||
lifted, cockpit `:nth-child` → modifier classes, form rules
|
||||
- [frontend/src/components/concepts/ScalesAppCyberpunk.css](frontend/src/components/concepts/ScalesAppCyberpunk.css)
|
||||
— reference only, not modified. Its hardcoded hex and `var(--cyan)`/`var(--green)`
|
||||
must **not** be copied; translate to the semantic tokens in
|
||||
[frontend/src/styles/tokens.css](frontend/src/styles/tokens.css) (`--accent`,
|
||||
`--success`). tokens.css is semantic-by-rule (header comment, lines 5-9).
|
||||
|
||||
No backend, serializer or API change. No new dependency.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd frontend && npx vitest run` — 201 tests still pass
|
||||
2. Browser check at http://localhost:5172, **both themes** via the header toggle,
|
||||
walking the full weighing lifecycle on one vehicle:
|
||||
- no vehicle selected → placeholder unchanged
|
||||
- fresh vehicle, no readings → 4 cards, all `—`, no dead buttons
|
||||
- after first Measure → value appears in TARE, Gross/Net still `—`
|
||||
- after second Measure → Tare+Gross+Net populated, Measure button gone,
|
||||
Print/Design appear in Net
|
||||
3. Confirm the merged card still shows: `Disconnected` when the bridge is down,
|
||||
`Waiting...` when connected with no reading, and the balanced/unbalanced
|
||||
colour on a live value
|
||||
4. Widths 1400 / 1100 / 600px — grid collapses 4 → 2 → 1 with no overflow
|
||||
5. Cockpit chrome (clipped corner, lit top edge, Net glow) lands on the right
|
||||
cards after the `:nth-child` → modifier rewrite
|
||||
6. Create form: 2-col above 768px, 1-col below; Cancel/Create right-aligned;
|
||||
inputs readable in cockpit
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,96 @@
|
||||
# UI Theming — switchable themes + "cockpit" dark skin
|
||||
|
||||
## Context
|
||||
|
||||
The current UI is functional but flat and generic. The user wants to try a
|
||||
different look (reference: a dark "cockpit" dashboard mockup — deep navy
|
||||
surfaces, cyan/amber accents, glow on the live reading, photo banner) **without
|
||||
losing the ability to switch back**.
|
||||
|
||||
Today there is no theming layer at all: **0 CSS custom properties, ~430
|
||||
hardcoded colours across 16 CSS files**. So the switch has to be built before
|
||||
any new skin can exist.
|
||||
|
||||
Key finding that reduces scope: `Main.jsx` **already has the card structure**
|
||||
the mockup shows — `.weighing-section` containing `.weight-card` elements for
|
||||
Tare / Gross / Scale / Net, each with header, value and meta. No restructuring
|
||||
of the detail panel is needed; this is a styling job.
|
||||
|
||||
## Approach
|
||||
|
||||
Token layer + `data-theme` attribute on `<html>`. Two themes:
|
||||
- `classic` — reproduces today's look exactly (the safe default)
|
||||
- `cockpit` — the new dark skin
|
||||
|
||||
### 1. Token layer
|
||||
New `frontend/src/styles/tokens.css`, imported once in `index.css`:
|
||||
|
||||
```css
|
||||
:root, :root[data-theme="classic"] {
|
||||
--bg, --surface, --surface-raised, --border,
|
||||
--text, --text-muted, --accent, --accent-contrast,
|
||||
--success, --warning, --danger,
|
||||
--radius, --shadow-card, --glow-accent
|
||||
}
|
||||
:root[data-theme="cockpit"] { /* same names, dark values */ }
|
||||
```
|
||||
Token NAMES are semantic (`--surface`), never literal (`--navy`), so a third
|
||||
theme is one block.
|
||||
|
||||
### 2. Convert existing CSS to var()
|
||||
Mechanical replacement of hardcoded colours. `classic` values are lifted from
|
||||
the current CSS so the default theme is **visually unchanged** — that is the
|
||||
acceptance test.
|
||||
|
||||
### 3. Theme switch
|
||||
- `ThemeContext` (mirrors the existing `AuthContext`/`NomenclatureContext`
|
||||
pattern in `frontend/src/contexts/`)
|
||||
- Sets `data-theme` on `document.documentElement`
|
||||
- Persists to `localStorage`, wrapped in try/catch (private mode can throw)
|
||||
- Toggle control in `Header.jsx`
|
||||
- **Default: `classic`** — nothing changes for operators unless they opt in
|
||||
|
||||
### 4. Cockpit theme + banner
|
||||
Dark palette, raised card surfaces, cyan accent, glow on the live reading,
|
||||
monospace weight readouts. Banner strip behind the vehicle title in
|
||||
`.vehicle-detail` — image asset TBD (CSS gradient placeholder until the user
|
||||
supplies one). Banner is cockpit-only and must collapse on short viewports;
|
||||
this may run on a weigh-station monitor.
|
||||
|
||||
## Staging
|
||||
|
||||
Deliberately two stages so a wrong palette is caught after 3 files, not 16.
|
||||
|
||||
**Stage 1** — tokens, ThemeContext, header toggle, and convert:
|
||||
- `frontend/src/components/Main.css` (68 colours)
|
||||
- `frontend/src/components/Header.css` (17)
|
||||
- `frontend/src/components/ScaleMonitor/ScaleMonitor.css` (47)
|
||||
- `frontend/src/index.css`, `frontend/src/App.css`
|
||||
|
||||
→ user reviews both themes on the main weighing screen.
|
||||
|
||||
**Stage 2** — remaining 13 files, once the direction is approved:
|
||||
NomenclatureManager (79), NomenclatureManagementOverlay (54),
|
||||
LicenseManager (45), ChangePasswordOverlay (22), ExtraCombinationsTable (20),
|
||||
DataDisplay (19), Login (15), NomenclatureEntryForm (14),
|
||||
NomenclatureDropdown (14), FormOverlay, ReportPreviewModal.
|
||||
|
||||
## Critical files
|
||||
|
||||
- `frontend/src/styles/tokens.css` (new)
|
||||
- `frontend/src/contexts/ThemeContext.jsx` (new)
|
||||
- `frontend/src/components/Header.jsx` — toggle control
|
||||
- `frontend/src/App.jsx` — wrap in ThemeProvider
|
||||
- `frontend/src/components/Main.css` — the main surface
|
||||
|
||||
Report editor CSS (`components/ReportEditor/`) is **out of scope**: the A4
|
||||
designer renders a paper page that must stay white regardless of theme.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npx vitest run` — 201 tests still pass
|
||||
2. Classic theme vs `git stash` screenshot: **visually identical**
|
||||
3. Toggle switches instantly, no reload; survives refresh (localStorage)
|
||||
4. Cockpit: check contrast on the live reading, balanced/unbalanced states,
|
||||
and that the banner collapses on a short viewport
|
||||
5. Real browser check of both themes on the main weighing screen
|
||||
@@ -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
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- [SSE operation_map fallback](sse-operation-map-fallback.md) — unmapped `sse_broadcast_update` action strings pass through to the frontend verbatim as `operation`.
|
||||
- [NomenclatureContext SSE dispatch structure](nomenclature-context-sse-dispatch.md) — how `handleSSEUpdate` routes object type to array key and operation to array mutation.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: nomenclature-context-sse-dispatch
|
||||
description: How handleSSEUpdate in NomenclatureContext.jsx routes an SSE message's object/operation to state mutations
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
`frontend/src/contexts/NomenclatureContext.jsx`'s `handleSSEUpdate(message)`:
|
||||
|
||||
1. Special-cases `object === 'nomenclature' | 'nomenclature_entry' | 'extra_combination'`
|
||||
and `object === 'invoice' | 'notice'` — these just fan out to `subscribersRef`
|
||||
callbacks and return early, bypassing the nomenclature-array logic entirely.
|
||||
2. Otherwise maps `message.object` (the SSE `sseObjectType`, e.g. `'vehicle'`) to a
|
||||
`nomenclatureKey` (e.g. `'vehicles'`) via `getNomenclatureKeyFromSSE`, which just
|
||||
looks up `NOMENCLATURES_CONFIG` by `sseObjectType` — this mapping is
|
||||
operation-agnostic, so it already works correctly for any new operation string
|
||||
on an existing object type (no changes needed there when adding new operations).
|
||||
3. Inside `setNomenclatures(prev => {...})`, an `if/else if` chain on `operation`
|
||||
decides how to mutate `prev[nomenclatureKey]`:
|
||||
- `'insert'`: skip if `id` already present, else `insertSorted`.
|
||||
- `'update'`: `updateSorted` (removes old, re-inserts at sorted position, or
|
||||
falls back to `insertSorted` if the item wasn't present at all).
|
||||
- Falls through to `return prev` (no-op) for anything unhandled — this was true
|
||||
even for the backend's own long-standing `'deleted'` action before this
|
||||
session; there was no delete/removal handling of any kind until Phase B of
|
||||
`.claude/plans/finalize-history-void.md` added `'archived'` (filter out by id)
|
||||
and `'voided'` (update-in-place if present, safe no-op if not) as new
|
||||
`else if` branches alongside `insert`/`update`.
|
||||
|
||||
`insertSorted`/`updateSorted` (same file, ~line 62 and ~90) are the two shared
|
||||
mutation helpers — always spread-based (`[...array]`, `.filter`, `.map`), never
|
||||
in-place `.push`/`.splice` on the original array. Any new operation case should
|
||||
reuse these two helpers rather than writing new sort/insert logic.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: sse-operation-map-fallback
|
||||
description: sse_broadcast_update's operation_map only maps created/updated/deleted; other action strings pass through unchanged to the frontend's `operation` field
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
`backend/scalesapp/sse.py`'s `sse_broadcast_update(object_name, action, data)` maps
|
||||
`action` through a small dict:
|
||||
|
||||
```python
|
||||
operation_map = {
|
||||
'created': 'insert',
|
||||
'updated': 'update',
|
||||
'deleted': 'delete'
|
||||
}
|
||||
...
|
||||
'operation': operation_map.get(action, action),
|
||||
```
|
||||
|
||||
Any action string NOT in that dict (e.g. `'archived'`, `'voided'`, added for the
|
||||
vehicle finalize/void feature) is **not translated** — it arrives client-side in
|
||||
`message.operation` exactly as the backend passed it, verbatim.
|
||||
|
||||
**Why:** confirmed by reading the actual source during Phase B of
|
||||
`.claude/plans/finalize-history-void.md` (finalize/history/void feature), rather
|
||||
than assuming the plan's own hedge ("mapped through operation_map, or handled as
|
||||
raw fallback — check which is simpler") was already resolved. It wasn't resolved
|
||||
in the plan text; the code settles it.
|
||||
|
||||
**How to apply:** when adding a new `handleSSEUpdate` case in
|
||||
`frontend/src/contexts/NomenclatureContext.jsx` for a new backend action, do NOT
|
||||
assume the operation string will be renamed/normalized — check `operation_map` in
|
||||
`backend/scalesapp/sse.py` first, and if the action isn't in the map, write the
|
||||
frontend case using the exact same string the backend passes as `action`. Do not
|
||||
add entries to `operation_map` yourself for a frontend-only task — that's a
|
||||
backend change and out of scope unless explicitly asked.
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
+223
-47
@@ -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):
|
||||
@@ -128,18 +159,25 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
tare_user_name = serializers.CharField(source='tare_user.username', read_only=True, default=None)
|
||||
gross_user_name = serializers.CharField(source='gross_user.username', read_only=True, default=None)
|
||||
net_user_name = serializers.CharField(source='net_user.username', read_only=True, default=None)
|
||||
void_user_name = serializers.CharField(source='void_user.username', read_only=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = Vehicle
|
||||
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid',
|
||||
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name',
|
||||
'tare', 'tare_date', 'tare_user', 'tare_user_name',
|
||||
'tare_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']
|
||||
'doc_number', 'extra',
|
||||
'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason']
|
||||
read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name',
|
||||
'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']
|
||||
'doc_number',
|
||||
'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason']
|
||||
|
||||
def create(self, validated_data):
|
||||
extra_data = validated_data.pop('extra', None)
|
||||
@@ -173,6 +211,58 @@ class VehicleSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError("Vehicle number cannot be empty")
|
||||
return value.strip()
|
||||
|
||||
def validate(self, attrs):
|
||||
if self.instance is None and not attrs.get('driver_name'):
|
||||
raise serializers.ValidationError({'driver_name': 'This field is required.'})
|
||||
return attrs
|
||||
|
||||
|
||||
class ExtraCombinationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ExtraCombination
|
||||
fields = ['id', 'data', 'use_count', 'last_used']
|
||||
read_only_fields = ['id', 'use_count', 'last_used']
|
||||
|
||||
def validate_data(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("Combination cannot be empty.")
|
||||
return validate_vehicle_extra_data(value, lookup_only=True)
|
||||
|
||||
|
||||
class ScaleProtocolSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScaleProtocol
|
||||
fields = ['id', 'name', 'kind', 'poll_command', 'poll_interval',
|
||||
'config', 'is_builtin', 'is_active']
|
||||
read_only_fields = ['id', 'is_builtin']
|
||||
|
||||
def validate_poll_interval(self, value):
|
||||
if value <= 0:
|
||||
raise serializers.ValidationError("Poll interval must be greater than zero.")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
kind = attrs.get('kind', getattr(self.instance, 'kind', None))
|
||||
config = attrs.get('config', getattr(self.instance, 'config', None) or {})
|
||||
|
||||
if kind == ScaleProtocol.FIXED_COLUMN:
|
||||
# A misconfigured offset silently clips a digit off every weight, so
|
||||
# reject anything unusable rather than storing it.
|
||||
errors = {}
|
||||
for key in ('weight_start', 'weight_length', 'stable_pos'):
|
||||
value = config.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
errors[key] = "Must be a non-negative integer."
|
||||
if not isinstance(config.get('weight_length'), bool) and isinstance(config.get('weight_length'), int) and config['weight_length'] < 1:
|
||||
errors['weight_length'] = "Must be at least 1."
|
||||
stable_char = config.get('stable_char', ' ')
|
||||
if not isinstance(stable_char, str) or len(stable_char) != 1:
|
||||
errors['stable_char'] = "Must be exactly one character."
|
||||
if errors:
|
||||
raise serializers.ValidationError({'config': errors})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class NomenclatureFieldSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -255,27 +345,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 +402,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 +432,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 +456,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 +510,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
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from api.models import User
|
||||
from vehicles.models import Vehicle
|
||||
|
||||
STATION = {'station_key': 'test-station'}
|
||||
|
||||
|
||||
class OneWeightTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user('op', password='x', can_measure=True)
|
||||
self.other = User.objects.create_user('first', password='x')
|
||||
self.client.force_authenticate(self.user)
|
||||
p = mock.patch('api.views._station_allowed', return_value=True)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch('api.views.sse_broadcast_update')
|
||||
self.sse = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def url(self, v):
|
||||
return f'/api/vehicles/{v.pk}/one-weight/'
|
||||
|
||||
def make(self, **kw):
|
||||
return Vehicle.objects.create(vehicle_number='CB1234', driver_name='D', **kw)
|
||||
|
||||
def test_moves_tare_to_gross_and_finalizes(self):
|
||||
when = timezone.now() - timedelta(hours=1)
|
||||
v = self.make(tare=1500, tare_date=when, tare_user=self.other, tare_manual=True,
|
||||
tare_manual_user=self.other, tare_manual_date=when, tare_manual_note='n')
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 200, r.content)
|
||||
v.refresh_from_db()
|
||||
self.assertEqual(v.gross, 1500)
|
||||
self.assertEqual(v.gross_date, when)
|
||||
self.assertEqual(v.gross_user, self.other)
|
||||
self.assertTrue(v.gross_manual)
|
||||
self.assertEqual(v.gross_manual_user, self.other)
|
||||
self.assertEqual(v.gross_manual_date, when)
|
||||
self.assertEqual(v.gross_manual_note, 'n')
|
||||
for f in ('tare', 'tare_date', 'tare_user', 'tare_manual_user',
|
||||
'tare_manual_date', 'tare_manual_note'):
|
||||
self.assertIsNone(getattr(v, f), f)
|
||||
self.assertFalse(v.tare_manual)
|
||||
self.assertEqual(v.net, 1500)
|
||||
self.assertEqual(v.net_user, self.user)
|
||||
self.assertIsNotNone(v.net_date)
|
||||
self.assertTrue(v.doc_number)
|
||||
self.assertEqual(r.data['doc_number'], v.doc_number)
|
||||
self.assertEqual(r.data['net'], 1500)
|
||||
self.assertIsNone(r.data['tare'])
|
||||
self.sse.assert_called_once()
|
||||
self.assertEqual(self.sse.call_args.args[:2], ('vehicle', 'updated'))
|
||||
|
||||
def test_gross_already_set(self):
|
||||
when = timezone.now()
|
||||
v = self.make(gross=9000, gross_date=when, gross_user=self.other)
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 200, r.content)
|
||||
v.refresh_from_db()
|
||||
self.assertEqual((v.gross, v.gross_user, v.gross_date), (9000, self.other, when))
|
||||
self.assertIsNone(v.tare)
|
||||
self.assertEqual(v.net, 9000)
|
||||
self.assertTrue(v.doc_number)
|
||||
|
||||
def _assert_400(self, v):
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('detail', r.data)
|
||||
self.sse.assert_not_called()
|
||||
|
||||
def test_already_finalized(self):
|
||||
self._assert_400(self.make(gross=5, net=5, doc_number='2026-1'))
|
||||
|
||||
def test_void(self):
|
||||
self._assert_400(self.make(tare=5, is_void=True))
|
||||
|
||||
def test_no_weight(self):
|
||||
self._assert_400(self.make())
|
||||
|
||||
def test_both_weights(self):
|
||||
self._assert_400(self.make(tare=5, gross=10))
|
||||
|
||||
def test_permission_denied(self):
|
||||
v = self.make(tare=5)
|
||||
self.client.force_authenticate(self.other)
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
v.refresh_from_db()
|
||||
self.assertEqual(v.tare, 5)
|
||||
self.assertIsNone(v.doc_number)
|
||||
|
||||
def test_station_not_licensed(self):
|
||||
v = self.make(tare=5)
|
||||
with mock.patch('api.views._station_allowed', return_value=False):
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_unknown_vehicle_404(self):
|
||||
r = self.client.post('/api/vehicles/999999/one-weight/', STATION, format='json')
|
||||
self.assertEqual(r.status_code, 404)
|
||||
@@ -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'),
|
||||
|
||||
+415
-42
@@ -1,17 +1,23 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from .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.db import transaction
|
||||
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):
|
||||
@@ -127,6 +133,24 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||
serializer.save(source_ip=self.get_client_ip(self.request))
|
||||
|
||||
|
||||
_WEIGHING_SUFFIXES = ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note')
|
||||
|
||||
|
||||
def _move_weighing(vehicle, src_prefix, dst_prefix):
|
||||
"""Copy a recorded weighing to the other slot, carrying all its provenance.
|
||||
|
||||
The source slot is left untouched (callers overwrite or clear it as needed).
|
||||
"""
|
||||
for suffix in _WEIGHING_SUFFIXES:
|
||||
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
|
||||
|
||||
|
||||
def _clear_weighing(vehicle, prefix):
|
||||
"""Reset every field of a weighing slot to its empty state."""
|
||||
for suffix in _WEIGHING_SUFFIXES:
|
||||
setattr(vehicle, f'{prefix}{suffix}', False if suffix == '_manual' else None)
|
||||
|
||||
|
||||
def _maybe_finalize(vehicle, user):
|
||||
"""If both tare and gross are set and net hasn't been calculated yet, compute net and assign doc_number."""
|
||||
if vehicle.tare is not None and vehicle.gross is not None and vehicle.net is None:
|
||||
@@ -137,6 +161,82 @@ 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_weighing(vehicle, src_prefix, dst_prefix)
|
||||
|
||||
if vehicle.tare is not None and vehicle.gross is not None:
|
||||
raise ValueError('Vehicle already has both weights.')
|
||||
|
||||
if vehicle.tare is None and vehicle.gross is None:
|
||||
apply('tare')
|
||||
elif vehicle.gross is None:
|
||||
# tare holds the first weighing - keep the lighter one as tare
|
||||
if value >= vehicle.tare:
|
||||
apply('gross')
|
||||
else:
|
||||
move('tare', 'gross')
|
||||
apply('tare')
|
||||
else:
|
||||
# gross set but tare empty (only reachable via direct API use)
|
||||
if value <= vehicle.gross:
|
||||
apply('tare')
|
||||
else:
|
||||
move('gross', 'tare')
|
||||
apply('gross')
|
||||
|
||||
vehicle.save()
|
||||
_maybe_finalize(vehicle, user)
|
||||
|
||||
|
||||
def _record_extra_combination(extra_data):
|
||||
"""Record the lookup/selector projection of a vehicle's extra data as a reusable combination.
|
||||
|
||||
The selector value is part of the combination: without it, "Ship + Varna Star"
|
||||
and "Warehouse + WH-3" would collapse into indistinguishable rows.
|
||||
"""
|
||||
if not extra_data:
|
||||
return
|
||||
|
||||
lookup_codes = set(
|
||||
Nomenclature.objects.filter(
|
||||
applies_to='vehicle',
|
||||
kind__in=[Nomenclature.LOOKUP, Nomenclature.SELECTOR],
|
||||
)
|
||||
.values_list('code', flat=True)
|
||||
)
|
||||
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
|
||||
if not combo:
|
||||
return
|
||||
|
||||
existing, created = ExtraCombination.objects.get_or_create(
|
||||
data_hash=combination_hash(combo), defaults={'data': combo}
|
||||
)
|
||||
if not created:
|
||||
existing.use_count += 1
|
||||
existing.last_used = timezone.now()
|
||||
existing.save()
|
||||
|
||||
action = 'created' if created else 'updated'
|
||||
sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data)
|
||||
|
||||
|
||||
class VehicleViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for vehicle management.
|
||||
@@ -146,15 +246,20 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
retrieve: Get a specific vehicle by ID
|
||||
update: Full update of vehicle and extra data
|
||||
partial_update: Partial update of vehicle
|
||||
destroy: Delete a vehicle (cascades to VehicleExtra)
|
||||
destroy: Disabled - vehicles are never deleted, see void() instead
|
||||
by_number: Get vehicle by vehicle_number
|
||||
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)
|
||||
one_weight: Finalize a vehicle weighed once (weight becomes gross, net = gross)
|
||||
void: Flag a completed vehicle as voided (corrective reversal, not a delete)
|
||||
mark_notice_printed: Signal that a completed vehicle's notice was printed/downloaded
|
||||
"""
|
||||
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
|
||||
serializer_class = VehicleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
filterset_fields = ['vehicle_number']
|
||||
filterset_fields = {
|
||||
'vehicle_number': ['exact'],
|
||||
'doc_number': ['isnull'],
|
||||
}
|
||||
search_fields = ['vehicle_number']
|
||||
ordering = ['vehicle_number']
|
||||
|
||||
@@ -162,16 +267,25 @@ 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
|
||||
instance.delete()
|
||||
sse_broadcast_update('vehicle', 'deleted', data)
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Vehicles are never deleted - a completed weighing is corrected with
|
||||
# /void/ instead, which keeps the record (and its doc_number) intact
|
||||
# and visible in History. Overriding destroy(), not perform_destroy():
|
||||
# ModelViewSet.destroy() calls perform_destroy() then always returns
|
||||
# 204, so neutering only perform_destroy would silently no-op instead
|
||||
# of telling the caller this action doesn't exist.
|
||||
return Response(
|
||||
{'detail': 'Vehicles cannot be deleted. Use /void/ on a completed vehicle instead.'},
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-number')
|
||||
def by_number(self, request):
|
||||
@@ -193,10 +307,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,38 +342,274 @@ 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)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='one-weight')
|
||||
def one_weight(self, request, pk=None):
|
||||
"""Finalize a vehicle weighed only once (external clients).
|
||||
|
||||
The single weight is treated as gross: net = gross, tare stays empty.
|
||||
"""
|
||||
if not request.user.can_measure:
|
||||
return Response({'detail': 'You do not have permission to measure.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
station_key = (request.data.get('station_key') or '').strip()
|
||||
if not _station_allowed(station_key):
|
||||
limit = licensing.effective_station_limit()
|
||||
return Response(
|
||||
{'detail': f'This station is not licensed to measure '
|
||||
f'(limit: {limit} stations).'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
with transaction.atomic():
|
||||
vehicle = get_object_or_404(self.get_queryset().select_for_update(of=('self',)),
|
||||
pk=pk)
|
||||
self.check_object_permissions(request, vehicle)
|
||||
|
||||
if vehicle.doc_number:
|
||||
return Response({'detail': 'This vehicle is already finalized.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if vehicle.is_void:
|
||||
return Response({'detail': 'A voided vehicle cannot be finalized.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if (vehicle.tare is None) == (vehicle.gross is None):
|
||||
return Response(
|
||||
{'detail': 'Exactly one weight must be recorded to finalize with one weight.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if vehicle.tare is not None:
|
||||
_move_weighing(vehicle, 'tare', 'gross')
|
||||
_clear_weighing(vehicle, 'tare')
|
||||
|
||||
now = timezone.now()
|
||||
vehicle.net = vehicle.gross
|
||||
vehicle.net_date = now
|
||||
vehicle.net_user = request.user
|
||||
vehicle.doc_number = generate_doc_number(now.year)
|
||||
vehicle.save()
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='void')
|
||||
def void(self, request, pk=None):
|
||||
"""Flag a completed vehicle as voided. Does not touch doc_number/net/tare/gross."""
|
||||
if not request.user.can_edit_documents:
|
||||
return Response({'detail': 'You do not have permission to void a document.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
vehicle = self.get_object()
|
||||
if not vehicle.doc_number:
|
||||
return Response({'detail': 'Only a completed vehicle can be voided.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reason = (request.data.get('reason') or '').strip()
|
||||
if not reason:
|
||||
return Response({'detail': 'A reason is required to void a document.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
vehicle.is_void = True
|
||||
vehicle.void_date = timezone.now()
|
||||
vehicle.void_user = request.user
|
||||
vehicle.void_reason = reason
|
||||
vehicle.save()
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'voided', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='mark-notice-printed')
|
||||
def mark_notice_printed(self, request, pk=None):
|
||||
"""Signal that the operator has acted on a completed vehicle's notice (print/download).
|
||||
|
||||
Makes no DB change - doc_number already marks the vehicle as finalized.
|
||||
This only broadcasts the SSE event that removes it from the active list.
|
||||
"""
|
||||
if not request.user.can_measure:
|
||||
return Response({'detail': 'You do not have permission to do this.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
vehicle = self.get_object()
|
||||
if not vehicle.doc_number:
|
||||
return Response({'detail': 'Only a completed vehicle can be archived.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'archived', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ExtraCombinationViewSet(mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
"""
|
||||
API endpoint for previously-used combinations of vehicle extra (lookup) data.
|
||||
|
||||
list: Get all combinations, most recently used first
|
||||
retrieve: Get a specific combination
|
||||
destroy: Delete a combination the user no longer needs
|
||||
|
||||
Rows are created/bumped automatically when a vehicle's extra data is saved.
|
||||
"""
|
||||
queryset = ExtraCombination.objects.all()
|
||||
serializer_class = ExtraCombinationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
ordering = ['-last_used']
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
data = ExtraCombinationSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('extra_combination', 'deleted', data)
|
||||
|
||||
|
||||
def _live_slots():
|
||||
return StationSlot.objects.filter(last_seen__gte=licensing.live_slot_cutoff())
|
||||
|
||||
|
||||
def _station_allowed(station_key):
|
||||
"""Whether this station may measure.
|
||||
|
||||
A station already holding a live slot is always allowed - including when the
|
||||
licence is full - so a brief disconnection does not lock a working station
|
||||
out behind its own expiring slot.
|
||||
"""
|
||||
if not station_key:
|
||||
return False
|
||||
if _live_slots().filter(station_key=station_key).exists():
|
||||
return True
|
||||
return _live_slots().count() < licensing.effective_station_limit()
|
||||
|
||||
|
||||
class LicenseView(APIView):
|
||||
"""Licence status, and installing a new licence key."""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
status_info = licensing.license_status()
|
||||
slots = _live_slots()
|
||||
payload = {
|
||||
**status_info,
|
||||
'stations_used': slots.count(),
|
||||
'components': licensing.machine_components(),
|
||||
}
|
||||
if request.user.is_admin:
|
||||
payload['slots'] = [
|
||||
{'station_key': s.station_key, 'com_port': s.com_port,
|
||||
'bridge_version': s.bridge_version, 'last_seen': s.last_seen}
|
||||
for s in slots
|
||||
]
|
||||
return Response(payload)
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_admin:
|
||||
return Response({'detail': 'Only administrators can install a licence.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
blob = (request.data.get('key_blob') or '').strip()
|
||||
if not blob:
|
||||
return Response({'detail': 'key_blob is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate before storing, so a bad paste cannot displace a working licence.
|
||||
valid, payload, reason = licensing.verify_license(blob)
|
||||
if not valid:
|
||||
return Response({'detail': reason}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
record = License.objects.first() or License()
|
||||
record.key_blob = blob
|
||||
record.grace_started = None
|
||||
record.save()
|
||||
|
||||
return Response(licensing.license_status())
|
||||
|
||||
|
||||
class LicenseHeartbeatView(APIView):
|
||||
"""Stations report in here so the server can count them.
|
||||
|
||||
The bridge deliberately does not talk to Django - the browser, which already
|
||||
talks to both, reports which bridge it is using. That keeps the bridge pure
|
||||
transport with no startup dependency on the server.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
station_key = (request.data.get('station_key') or '').strip()
|
||||
if not station_key:
|
||||
return Response({'detail': 'station_key is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
limit = licensing.effective_station_limit()
|
||||
existing = StationSlot.objects.filter(station_key=station_key).first()
|
||||
live = _live_slots()
|
||||
|
||||
if existing is None and live.count() >= limit:
|
||||
return Response({
|
||||
'allowed': False,
|
||||
'stations_used': live.count(),
|
||||
'stations_licensed': limit,
|
||||
'detail': f'Licence limit reached ({limit} stations)',
|
||||
}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
slot, _ = StationSlot.objects.update_or_create(
|
||||
station_key=station_key,
|
||||
defaults={
|
||||
'bridge_version': request.data.get('bridge_version', '') or '',
|
||||
'com_port': request.data.get('com_port', '') or '',
|
||||
},
|
||||
)
|
||||
# update_or_create does not touch auto_now when nothing changed
|
||||
slot.save(update_fields=['last_seen'])
|
||||
|
||||
return Response({
|
||||
'allowed': True,
|
||||
'stations_used': _live_slots().count(),
|
||||
'stations_licensed': limit,
|
||||
})
|
||||
|
||||
|
||||
class ScaleProtocolViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for scale protocol definitions.
|
||||
|
||||
The frontend caches these and uses them to parse raw serial lines, so adding
|
||||
a scale type needs no change to the serial bridge running on station PCs.
|
||||
Built-in protocols cannot be deleted.
|
||||
"""
|
||||
queryset = ScaleProtocol.objects.all()
|
||||
serializer_class = ScaleProtocolSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
search_fields = ['name']
|
||||
ordering = ['name']
|
||||
|
||||
def perform_create(self, serializer):
|
||||
instance = serializer.save()
|
||||
sse_broadcast_update('scale_protocol', 'created', ScaleProtocolSerializer(instance).data)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
instance = serializer.save()
|
||||
sse_broadcast_update('scale_protocol', 'updated', ScaleProtocolSerializer(instance).data)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.is_builtin:
|
||||
return Response({'detail': 'Built-in protocols cannot be deleted.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
data = ScaleProtocolSerializer(instance).data
|
||||
instance.delete()
|
||||
sse_broadcast_update('scale_protocol', 'deleted', data)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class NomenclatureViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
@@ -385,7 +758,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']
|
||||
|
||||
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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,10 +2,12 @@ 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
|
||||
psycopg==3.3.2
|
||||
psycopg-binary==3.3.2
|
||||
uvicorn[standard]==0.34.0
|
||||
whitenoise==6.9.0
|
||||
whitenoise==6.9.0
|
||||
cryptography==50.0.1
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 6.0.1 on 2026-09-17 14:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("vehicles", "0005_extracombination"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_manual_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_manual_note",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="gross_manual_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_gross_manual",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_manual_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_manual_note",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="tare_manual_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_tare_manual",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.1 on 2026-09-21 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"vehicles",
|
||||
"0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="driver_name",
|
||||
field=models.CharField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 6.0.1 on 2026-09-22 17:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("vehicles", "0007_vehicle_driver_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="is_void",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="void_date",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="void_reason",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vehicle",
|
||||
name="void_user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="user_vehicle_void",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="vehicle",
|
||||
name="vehicle_number",
|
||||
field=models.CharField(max_length=15),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="vehicle",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("doc_number__isnull", True)),
|
||||
fields=("vehicle_number",),
|
||||
name="unique_active_vehicle_number",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,6 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from typing import Required
|
||||
from django.db import models
|
||||
|
||||
@@ -5,20 +8,27 @@ from api.models import User
|
||||
|
||||
# Create your models here.
|
||||
class Vehicle(models.Model):
|
||||
vehicle_number = models.CharField(max_length=15, unique=True)
|
||||
vehicle_number = models.CharField(max_length=15)
|
||||
trailer1_number = models.CharField(max_length=15, null=True, blank=True)
|
||||
trailer2_number = models.CharField(max_length=15, null=True, blank=True)
|
||||
driver_pid = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
driver_name = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
tare = models.IntegerField(null=True, blank=True)
|
||||
tare_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)
|
||||
@@ -26,7 +36,19 @@ class Vehicle(models.Model):
|
||||
|
||||
doc_number = models.CharField(max_length=20, null=True, blank=True, unique=True)
|
||||
|
||||
is_void = models.BooleanField(default=False)
|
||||
void_date = models.DateTimeField(null=True, blank=True)
|
||||
void_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_void', on_delete=models.SET_NULL)
|
||||
void_reason = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['vehicle_number'],
|
||||
condition=models.Q(doc_number__isnull=True),
|
||||
name='unique_active_vehicle_number',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vehicle_number}"
|
||||
@@ -35,4 +57,28 @@ class VehicleExtra(models.Model):
|
||||
vehicle = models.OneToOneField(
|
||||
Vehicle, on_delete=models.CASCADE, related_name="extra"
|
||||
)
|
||||
data = models.JSONField(default=dict)
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
|
||||
def combination_hash(data):
|
||||
"""Order-independent canonical hash of a {nomenclature_code: entry_id} dict."""
|
||||
canonical = json.dumps(data, sort_keys=True, separators=(',', ':'))
|
||||
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class ExtraCombination(models.Model):
|
||||
"""A previously-used combination of lookup nomenclature values for a vehicle's extra data."""
|
||||
data = models.JSONField(default=dict)
|
||||
data_hash = models.CharField(max_length=64, unique=True, editable=False)
|
||||
use_count = models.IntegerField(default=1)
|
||||
last_used = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-last_used"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.data_hash = combination_hash(self.data)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.data} (x{self.use_count})"
|
||||
@@ -4,4 +4,10 @@ from .models import Vehicle
|
||||
class VehicleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Vehicle
|
||||
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user']
|
||||
fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user']
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Require driver_name on creation only; existing vehicles may have it as NULL."""
|
||||
if self.instance is None and not attrs.get('driver_name'):
|
||||
raise serializers.ValidationError({'driver_name': 'This field is required.'})
|
||||
return attrs
|
||||
+2
-2
@@ -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 @@
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
VITE_API_URL=http://localhost:8002
|
||||
|
||||
+4
-4
@@ -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>
|
||||
@@ -1,14 +1,3 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@contexts/*": ["contexts/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
"@services/*": ["services/*"],
|
||||
"@assets/*": ["assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Generated
+1390
-15243
File diff suppressed because it is too large
Load Diff
+13
-12
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -1,68 +1,68 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: white;
|
||||
padding: 12px 20px;
|
||||
background: var(--surface);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
gap: var(--space-5);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: #4CAF50;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--success);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f44336;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f44336;
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
margin: var(--space-5) auto;
|
||||
padding: 0 var(--space-5);
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 12px 20px;
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f5c6cb;
|
||||
margin-bottom: var(--space-5);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
padding: var(--space-10);
|
||||
font-size: var(--fs-xl);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 10px;
|
||||
padding: 0 var(--space-2-5);
|
||||
}
|
||||
}
|
||||
|
||||
+56
-9
@@ -1,14 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, Suspense, lazy } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
// import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Login from './components/Users/Login';
|
||||
import Main from './components/Main';
|
||||
import ReportEditor from './components/ReportEditor/ReportEditor';
|
||||
import UserManager from './components/Users/UserManager';
|
||||
import History from './components/History';
|
||||
import RailLayout from './components/RailLayout';
|
||||
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'));
|
||||
|
||||
// UI concept preview — a standalone mockup at /concept/cyberpunk, deliberately
|
||||
// OUTSIDE the auth gate and the providers. It ships its own :root variables and
|
||||
// styles body/*, which would fight the app's theme tokens if it were ever
|
||||
// rendered alongside the real UI; lazy-loading keeps its CSS out of the bundle
|
||||
// until that route is opened. Nothing in the app imports it.
|
||||
const ScalesAppCyberpunk = lazy(() => import('./components/concepts/ScalesAppCyberpunk'));
|
||||
|
||||
// function MainApp() {
|
||||
// const [selectedPort, setSelectedPort] = useState(null);
|
||||
@@ -58,11 +74,23 @@ 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 element={<RailLayout />}>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
|
||||
<Route path="/scale-monitor" element={currentUser?.is_admin ? <ScaleMonitor /> : <Navigate to="/" />} />
|
||||
<Route path="/license" element={currentUser?.is_admin ? <LicenseManager /> : <Navigate to="/" />} />
|
||||
</Route>
|
||||
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/report-editor-a4" element={currentUser?.can_edit_documents ? <A4ReportEditor /> : <Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</LicenseProvider>
|
||||
</ScaleProtocolProvider>
|
||||
</NomenclatureDataProvider>
|
||||
</NomenclatureProvider>
|
||||
);
|
||||
@@ -72,9 +100,28 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
<Routes>
|
||||
{/* Concept preview, no auth and no providers — see the note above. */}
|
||||
<Route
|
||||
path="/concept/cyberpunk"
|
||||
element={
|
||||
<Suspense fallback={<div className="loading"><p>Loading concept...</p></div>}>
|
||||
<ScalesAppCyberpunk />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
/* Outermost, so the login screen and any overlay are themed too. */
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,129 @@
|
||||
.extra-combos {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.extra-combos h3 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.extra-combos-error {
|
||||
padding: var(--space-1-5) var(--space-2-5);
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.extra-combos-table-container {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar-track {
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.extra-combos-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.extra-combos-table thead {
|
||||
background-color: var(--surface-sunken);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.extra-combos-table th {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
text-align: left;
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.extra-combos-th-actions {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.extra-combos-table td {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.extra-combos-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.extra-combos-row:hover {
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.extra-combos-td-actions {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.extra-combos-delete-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0 var(--space-1-5);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.extra-combos-delete-btn:hover {
|
||||
background-color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.extra-combos-stale {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.extra-combos-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-4) !important;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
||||
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
|
||||
import api from '../services/api';
|
||||
import './ExtraCombinationsTable.css';
|
||||
|
||||
export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
|
||||
const { subscribeToUpdates } = useNomenclatures();
|
||||
const { getDisplayValue } = useNomenclatureData();
|
||||
|
||||
const [combinations, setCombinations] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// subscribeToUpdates is not memoized in NomenclatureContext - hold it in a ref
|
||||
// so the SSE effect below runs once instead of on every provider render.
|
||||
const subscribeRef = useRef(subscribeToUpdates);
|
||||
subscribeRef.current = subscribeToUpdates;
|
||||
|
||||
// Lookups and selectors form a combination: the selector value is what makes
|
||||
// "Ship + cargo" and "Warehouse + cargo" distinct rows rather than one.
|
||||
const lookupNomenclatures = nomenclatures.filter(
|
||||
n => n.kind === 'lookup' || n.kind === 'selector'
|
||||
);
|
||||
|
||||
// A selector stores its option value, not an entry id, so it resolves to the
|
||||
// option's label instead of going through getDisplayValue.
|
||||
const cellLabel = (nom, value) => {
|
||||
if (nom.kind !== 'selector') return getDisplayValue(nom.code, value);
|
||||
const option = (nom.selector_options || []).find(o => o.value === value);
|
||||
return option ? option.label : '';
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/extra-combinations/');
|
||||
setCombinations(res.data.results || res.data);
|
||||
} catch {
|
||||
setError('Failed to load saved combinations');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Realtime: rows are created/bumped by the backend when extra data is saved
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeRef.current((message) => {
|
||||
if (message.object !== 'extra_combination') return;
|
||||
const { operation, data } = message;
|
||||
|
||||
setCombinations(prev => {
|
||||
if (operation === 'delete') {
|
||||
return prev.filter(c => c.id !== data.id);
|
||||
}
|
||||
// insert or update: drop any existing copy, put it on top (most recent first)
|
||||
return [data, ...prev.filter(c => c.id !== data.id)];
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (e, id) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Delete this combination?')) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.delete(`/api/extra-combinations/${id}/`);
|
||||
} catch {
|
||||
setError('Failed to delete combination');
|
||||
}
|
||||
};
|
||||
|
||||
if (lookupNomenclatures.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="extra-combos">
|
||||
<h3>Previously Used</h3>
|
||||
{error && <div className="extra-combos-error">{error}</div>}
|
||||
<div className="extra-combos-table-container">
|
||||
<table className="extra-combos-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{lookupNomenclatures.map(nom => (
|
||||
<th key={nom.code}>{nom.name}</th>
|
||||
))}
|
||||
<th className="extra-combos-th-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{combinations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={lookupNomenclatures.length + 1} className="extra-combos-empty">
|
||||
No saved combinations yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
combinations.map(combo => (
|
||||
<tr
|
||||
key={combo.id}
|
||||
className="extra-combos-row"
|
||||
onClick={() => onApply(combo.data)}
|
||||
title="Click to fill the fields below"
|
||||
>
|
||||
{lookupNomenclatures.map(nom => {
|
||||
const entryId = combo.data[nom.code];
|
||||
const label = entryId != null ? cellLabel(nom, entryId) : '';
|
||||
return (
|
||||
<td key={nom.code}>
|
||||
{entryId != null && !label
|
||||
? <span className="extra-combos-stale">#{entryId}</span>
|
||||
: label}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="extra-combos-td-actions">
|
||||
<button
|
||||
className="extra-combos-delete-btn"
|
||||
onClick={(e) => handleDelete(e, combo.id)}
|
||||
title="Delete combination"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
align-items: flex-start;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
||||
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
|
||||
import './Header.css';
|
||||
|
||||
function Header() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
|
||||
|
||||
const getInitials = (user) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||
}
|
||||
return user.username.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
setShowPasswordOverlay(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
{currentUser?.can_edit_documents && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/report-editor')}
|
||||
title="Report Editor"
|
||||
>
|
||||
📝
|
||||
</button>
|
||||
)}
|
||||
{currentUser?.can_manage_entities && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => setShowNomenclatureManager(true)}
|
||||
title="Nomenclatures"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
)}
|
||||
{currentUser?.is_admin && (
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/users')}
|
||||
title="User Manager"
|
||||
>
|
||||
👥
|
||||
</button>
|
||||
)}
|
||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<div className="user-info">
|
||||
<span className="user-name">{currentUser?.username}</span>
|
||||
<span className="user-role">({currentUser?.role})</span>
|
||||
</div>
|
||||
<div className="avatar-container">
|
||||
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
|
||||
{getInitials(currentUser || {})}
|
||||
</div>
|
||||
<button className="logout-button" onClick={logout} title="Logout">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showPasswordOverlay && (
|
||||
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
|
||||
)}
|
||||
|
||||
{showNomenclatureManager && (
|
||||
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,182 @@
|
||||
.history {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--space-5);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-error {
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.history-table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
text-align: left;
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.history-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-8) !important;
|
||||
}
|
||||
|
||||
.history-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border: 1px solid;
|
||||
border-radius: 20px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-badge--ok {
|
||||
color: var(--success);
|
||||
border-color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.history-badge--void {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.history-void-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-void-btn:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.history-void-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.history-void-inline input {
|
||||
padding: var(--space-1-5) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-sm);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.history-void-error {
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.history-void-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
.history-void-actions button {
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.history-pager button {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-pager button:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.history-pager button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.history-pager .history-page-btn--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.history-page-gap {
|
||||
color: var(--text-muted);
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
|
||||
import { buildVehicleReportData } from '../services/vehicleReportData';
|
||||
import ReportPreviewModal from './ReportPreviewModal';
|
||||
import api from '../services/api';
|
||||
import './History.css';
|
||||
|
||||
const PAGE_SIZE = 100; // matches the backend's DRF PAGE_SIZE
|
||||
|
||||
export default function History() {
|
||||
const { currentUser } = useAuth();
|
||||
const { definitions, entries } = useNomenclatureData();
|
||||
|
||||
const [vehicles, setVehicles] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
// django-filter's OrderingFilter has no explicit `ordering_fields` set on
|
||||
// VehicleViewSet, so it falls back to allowing any non-write-only
|
||||
// serializer field - net_date included - meaning ?ordering=-net_date
|
||||
// works server-side today. No client-side fallback sort is needed.
|
||||
const [previewVehicle, setPreviewVehicle] = useState(null);
|
||||
const [voidingId, setVoidingId] = useState(null);
|
||||
const [voidReason, setVoidReason] = useState('');
|
||||
const [voidError, setVoidError] = useState('');
|
||||
const [isVoiding, setIsVoiding] = useState(false);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
// Windowed page list (first, last, and a range around the current page)
|
||||
// so the pager stays usable once History accumulates hundreds of pages -
|
||||
// nothing in this feature ever deletes a vehicle, so unbounded growth is
|
||||
// the expected long-run case, not an edge case.
|
||||
const pageList = useMemo(() => {
|
||||
const spread = 2;
|
||||
const pages = new Set([1, totalPages]);
|
||||
for (let n = page - spread; n <= page + spread; n++) {
|
||||
if (n >= 1 && n <= totalPages) pages.add(n);
|
||||
}
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
const withGaps = [];
|
||||
sorted.forEach((n, i) => {
|
||||
if (i > 0 && n - sorted[i - 1] > 1) withGaps.push('...');
|
||||
withGaps.push(n);
|
||||
});
|
||||
return withGaps;
|
||||
}, [page, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
api.get('/api/vehicles/', {
|
||||
params: { 'doc_number__isnull': false, ordering: '-net_date', page },
|
||||
})
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
setVehicles(res.data.results || []);
|
||||
setCount(res.data.count || 0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(err.response?.data?.detail || 'Failed to load history');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [page]);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const openPreview = (vehicle) => {
|
||||
setPreviewVehicle(vehicle);
|
||||
};
|
||||
|
||||
const previewData = useMemo(
|
||||
() => previewVehicle ? buildVehicleReportData(previewVehicle, definitions, entries) : null,
|
||||
[previewVehicle, definitions, entries]
|
||||
);
|
||||
|
||||
const startVoid = (vehicle) => {
|
||||
setVoidingId(vehicle.id);
|
||||
setVoidReason('');
|
||||
setVoidError('');
|
||||
};
|
||||
|
||||
const cancelVoid = () => {
|
||||
setVoidingId(null);
|
||||
setVoidReason('');
|
||||
setVoidError('');
|
||||
};
|
||||
|
||||
const confirmVoid = async () => {
|
||||
if (!voidReason.trim()) {
|
||||
setVoidError('A reason is required to void a document.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Void this document? This flags it permanently and cannot be undone casually.')) {
|
||||
return;
|
||||
}
|
||||
setIsVoiding(true);
|
||||
setVoidError('');
|
||||
try {
|
||||
const res = await api.post(`/api/vehicles/${voidingId}/void/`, { reason: voidReason.trim() });
|
||||
// History has its own separate fetch/state (not NomenclatureContext's
|
||||
// vehicles array), so the voided row is updated in place here from
|
||||
// the API response rather than relying on the SSE 'voided' handler.
|
||||
setVehicles(prev => prev.map(v => v.id === res.data.id ? res.data : v));
|
||||
cancelVoid();
|
||||
} catch (err) {
|
||||
setVoidError(err.response?.data?.detail || 'Failed to void document');
|
||||
} finally {
|
||||
setIsVoiding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="history">
|
||||
<h2 className="history-title">History</h2>
|
||||
|
||||
{error && <div className="history-error">{error}</div>}
|
||||
|
||||
<div className="history-table-wrap">
|
||||
<table className="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vehicle</th>
|
||||
<th>Doc #</th>
|
||||
<th>Net (kg)</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="history-empty">Loading...</td></tr>
|
||||
) : vehicles.length === 0 ? (
|
||||
<tr><td colSpan={6} className="history-empty">No completed vehicles yet</td></tr>
|
||||
) : (
|
||||
vehicles.map(v => (
|
||||
<tr key={v.id} className="history-row" onClick={() => openPreview(v)}>
|
||||
<td>{v.vehicle_number}</td>
|
||||
<td>{v.doc_number}</td>
|
||||
<td>{v.net != null ? v.net : '—'}</td>
|
||||
<td>{formatDate(v.net_date)}</td>
|
||||
<td>
|
||||
<span className={`history-badge ${v.is_void ? 'history-badge--void' : 'history-badge--ok'}`}>
|
||||
{v.is_void ? 'Voided' : 'Completed'}
|
||||
</span>
|
||||
</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
{currentUser?.can_edit_documents && !v.is_void && (
|
||||
voidingId === v.id ? (
|
||||
<div className="history-void-inline">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason..."
|
||||
value={voidReason}
|
||||
onChange={e => setVoidReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{voidError && <div className="history-void-error">{voidError}</div>}
|
||||
<div className="history-void-actions">
|
||||
<button onClick={confirmVoid} disabled={isVoiding}>
|
||||
{isVoiding ? 'Voiding...' : 'Confirm'}
|
||||
</button>
|
||||
<button onClick={cancelVoid} disabled={isVoiding}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="history-void-btn" onClick={() => startVoid(v)}>
|
||||
Void
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="history-pager">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{pageList.map((n, i) => (
|
||||
n === '...' ? (
|
||||
<span key={`gap-${i}`} className="history-page-gap">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={n}
|
||||
className={n === page ? 'history-page-btn--active' : ''}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={n === page}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewData && (
|
||||
<ReportPreviewModal
|
||||
vehicleData={previewData}
|
||||
onClose={() => setPreviewVehicle(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
.license-manager {
|
||||
flex: 1 0 auto; /* fill the rail layout's content column */
|
||||
background-color: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.lm-body {
|
||||
padding: var(--space-5);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.lm-loading,
|
||||
.lm-empty {
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.lm-toolbar {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.lm-back {
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.lm-back:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lm-ok,
|
||||
.lm-error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-base);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.lm-ok {
|
||||
background-color: var(--success-soft);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.lm-error {
|
||||
background-color: var(--danger-soft);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.lm-panel {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.lm-panel h3 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.lm-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: 4px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--space-2-5);
|
||||
}
|
||||
|
||||
.lm-badge--ok {
|
||||
background-color: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.lm-badge--warn {
|
||||
background-color: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.lm-facts {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: var(--space-1-5) var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lm-facts dt {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.lm-facts dd {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lm-reason {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lm-grace {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lm-hint {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lm-fingerprint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.lm-fingerprint code {
|
||||
flex: 1;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
background-color: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xl);
|
||||
letter-spacing: 1px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.lm-components {
|
||||
font-size: var(--fs-xs) !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.lm-fingerprint button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-base);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lm-fingerprint button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lm-details {
|
||||
margin-top: var(--space-2-5);
|
||||
}
|
||||
|
||||
.lm-details summary {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.lm-blob {
|
||||
width: 100%;
|
||||
padding: var(--space-2-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-sm);
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.lm-install {
|
||||
margin-top: var(--space-2-5);
|
||||
padding: var(--space-2-5) var(--space-5);
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--accent-contrast);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lm-install:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px var(--accent-soft);
|
||||
}
|
||||
|
||||
.lm-install:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.lm-table th {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-sm);
|
||||
border-bottom: 2px solid var(--border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
background-color: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.lm-table td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLicense } from '../../contexts/LicenseContext';
|
||||
import api from '../../services/api';
|
||||
import './LicenseManager.css';
|
||||
|
||||
export default function LicenseManager() {
|
||||
const navigate = useNavigate();
|
||||
const { status, refreshStatus } = useLicense();
|
||||
|
||||
const [blob, setBlob] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [copied, setCopied] = useState('');
|
||||
|
||||
const copy = (text, label) => {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => { setCopied(label); setTimeout(() => setCopied(''), 2000); },
|
||||
() => setError('Could not copy - select and copy manually')
|
||||
);
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!blob.trim()) return;
|
||||
setSaving(true); setError(''); setMessage('');
|
||||
try {
|
||||
await api.post('/api/license/', { key_blob: blob.trim() });
|
||||
await refreshStatus();
|
||||
setBlob('');
|
||||
setMessage('Licence installed.');
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Could not install licence');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="license-manager">
|
||||
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const componentsJson = JSON.stringify(status.components || {});
|
||||
|
||||
return (
|
||||
<div className="license-manager">
|
||||
<div className="lm-body">
|
||||
<div className="lm-toolbar">
|
||||
<button className="lm-back" onClick={() => navigate('/')}>← 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 — 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>
|
||||
);
|
||||
}
|
||||
+1189
-149
File diff suppressed because it is too large
Load Diff
+604
-158
@@ -1,27 +1,43 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import useSerialData from '../hooks/useSerialData';
|
||||
import useStagedList, { prefersReducedMotion } from '../hooks/useStagedList';
|
||||
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 { LIST_STAGING, listStagingStyle, PendingMarker, RevealPlate, measureOpeningRow, shouldHoldList } from './VehicleListStaging';
|
||||
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
|
||||
import { buildVehicleReportData, vehicleNetWeight } from '../services/vehicleReportData';
|
||||
import api from '../services/api';
|
||||
import './Main.css';
|
||||
import './License/LicenseManager.css';
|
||||
|
||||
const getVehicleKey = v => v.id;
|
||||
|
||||
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 } = useLicense();
|
||||
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
const [newExtraData, setNewExtraData] = useState({});
|
||||
const [error, setError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showNoticeReport, setShowNoticeReport] = useState(false);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const [isOneWeighing, setIsOneWeighing] = useState(false);
|
||||
const [showManualForm, setShowManualForm] = useState(false);
|
||||
const [manualForm, setManualForm] = useState({ value: '', note: '' });
|
||||
|
||||
// Nomenclature data from context
|
||||
const { definitions, entries } = useNomenclatureData();
|
||||
@@ -33,19 +49,116 @@ 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;
|
||||
|
||||
// Equalizer bar history: last 40 readings, oldest first, newest at the
|
||||
// right. A ticking interval, not a useEffect keyed on value changes - a
|
||||
// static weight (scale settled, or nothing on it) would otherwise never
|
||||
// push a new bar at all, since the effect only reruns when the value
|
||||
// itself changes. Sampling on a timer keeps the strip moving continuously
|
||||
// whether or not the reading is currently changing.
|
||||
const EQUALIZER_BAR_COUNT = 40;
|
||||
const EQUALIZER_SAMPLE_INTERVAL_MS = 250;
|
||||
const EQUALIZER_FIXED_MAX_KG = 60000;
|
||||
const [equalizerBars, setEqualizerBars] = useState([]);
|
||||
// 'fixed' makes bar height comparable across vehicles/sessions; 'auto'
|
||||
// scales to whatever is on screen right now, so a light load still fills
|
||||
// the card. Not persisted - always starts on 'fixed' per session.
|
||||
const [equalizerScaleMode, setEqualizerScaleMode] = useState('fixed');
|
||||
const latestReadingRef = useRef({ weight: null, balanced: null });
|
||||
latestReadingRef.current = { weight: currentWeight, balanced: isBalanced };
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const { weight, balanced } = latestReadingRef.current;
|
||||
if (weight === null || isNaN(weight)) return;
|
||||
setEqualizerBars(prev => [...prev, { value: weight, balanced }].slice(-EQUALIZER_BAR_COUNT));
|
||||
}, EQUALIZER_SAMPLE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Padded to a fixed 40 slots (empty ones render as zero-height bars).
|
||||
// 'fixed' scales against EQUALIZER_FIXED_MAX_KG (clamped at 100% above
|
||||
// it); 'auto' scales against the tallest bar currently visible, so the
|
||||
// row re-scales as the window slides instead of clipping.
|
||||
const equalizerSlots = useMemo(() => {
|
||||
const maxValue = equalizerScaleMode === 'fixed'
|
||||
? EQUALIZER_FIXED_MAX_KG
|
||||
: Math.max(1, ...equalizerBars.map(b => Math.abs(b.value)));
|
||||
const padding = Array(Math.max(0, EQUALIZER_BAR_COUNT - equalizerBars.length)).fill(null);
|
||||
return [...padding, ...equalizerBars].map(bar => ({
|
||||
balanced: bar?.balanced ?? null,
|
||||
heightPct: bar ? Math.min(100, Math.max(6, (Math.abs(bar.value) / maxValue) * 100)) : 0,
|
||||
}));
|
||||
}, [equalizerBars, equalizerScaleMode]);
|
||||
|
||||
// Tell the bridge how to talk to this scale. Polled indicators stay silent
|
||||
// until asked, so the bridge must send the command on its own timer - a
|
||||
// browser tab cannot be the heartbeat (background tabs get throttled, and a
|
||||
// closed tab would stop the scale). The UI only supplies the string.
|
||||
useEffect(() => {
|
||||
if (!selectedProtocol) return;
|
||||
const serialUrl = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
|
||||
fetch(serialUrl + '/poll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
command: selectedProtocol.poll_command || '',
|
||||
interval: selectedProtocol.poll_interval || 0.2,
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Bridge not reachable - the SSE connection state already shows this.
|
||||
});
|
||||
}, [selectedProtocol]);
|
||||
|
||||
// Selected vehicle object (refreshes when vehicles list updates via SSE)
|
||||
const selectedVehicle = useMemo(
|
||||
@@ -53,12 +166,60 @@ export default function Main() {
|
||||
[vehicles, selectedVehicleId]
|
||||
);
|
||||
|
||||
// Filtered vehicles
|
||||
// While the mouse is over the list, staged changes wait (useStagedList
|
||||
// `hold`). Only mouse/pen hold, so a touch screen never gets stuck. A
|
||||
// click on a row releases; a mouse move REHOLD_PX away from the click
|
||||
// point holds again (shouldHoldList).
|
||||
const [isListHeld, setIsListHeld] = useState(false);
|
||||
const lastPointerRef = useRef(null);
|
||||
const holdReleasedAtRef = useRef(null);
|
||||
const trackListPointer = e => {
|
||||
if (e.pointerType === 'mouse' || e.pointerType === 'pen') {
|
||||
lastPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
};
|
||||
const handleListPointerOver = e => {
|
||||
trackListPointer(e);
|
||||
if (!shouldHoldList(e, holdReleasedAtRef.current)) return;
|
||||
holdReleasedAtRef.current = null;
|
||||
if (!isListHeld) setIsListHeld(true);
|
||||
};
|
||||
const handleListPointerLeave = () => {
|
||||
holdReleasedAtRef.current = null;
|
||||
setIsListHeld(false);
|
||||
};
|
||||
const releaseListHoldOnClick = () => {
|
||||
holdReleasedAtRef.current = lastPointerRef.current;
|
||||
setIsListHeld(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
const release = () => setIsListHeld(false);
|
||||
const handleVisibilityChange = () => { if (document.hidden) release(); };
|
||||
window.addEventListener('blur', release);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('blur', release);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The list lags the live data so rows never shift under the cursor
|
||||
// unannounced; the search filter applies to it instantly.
|
||||
const stagedVehicles = useStagedList(vehicles, {
|
||||
getKey: getVehicleKey,
|
||||
delay: LIST_STAGING.delay,
|
||||
distortMs: LIST_STAGING.distortMs,
|
||||
enterMs: LIST_STAGING.enterMs,
|
||||
animationMs: LIST_STAGING.exitMs,
|
||||
hold: isListHeld,
|
||||
releaseStagger: LIST_STAGING.releaseStagger,
|
||||
});
|
||||
const filteredVehicles = useMemo(() => {
|
||||
if (!searchQuery.trim()) return vehicles;
|
||||
if (!searchQuery.trim()) return stagedVehicles;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q));
|
||||
}, [vehicles, searchQuery]);
|
||||
return stagedVehicles.filter(({ item }) => item.vehicle_number.toLowerCase().includes(q));
|
||||
}, [stagedVehicles, searchQuery]);
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
// When vehicle selection changes, load its extra data
|
||||
useEffect(() => {
|
||||
@@ -73,7 +234,7 @@ export default function Main() {
|
||||
const handleNewVehicle = () => {
|
||||
setSelectedVehicleId(null);
|
||||
setShowNewForm(true);
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
setNewExtraData({});
|
||||
setError('');
|
||||
};
|
||||
@@ -81,7 +242,7 @@ export default function Main() {
|
||||
// Cancel new vehicle form
|
||||
const handleCancelNew = () => {
|
||||
setShowNewForm(false);
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
setNewExtraData({});
|
||||
setError('');
|
||||
};
|
||||
@@ -97,16 +258,18 @@ export default function Main() {
|
||||
trailer1_number: newForm.trailer1_number.trim() || null,
|
||||
trailer2_number: newForm.trailer2_number.trim() || null,
|
||||
driver_pid: newForm.driver_pid.trim() || null,
|
||||
driver_name: newForm.driver_name.trim(),
|
||||
};
|
||||
// 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);
|
||||
setShowNewForm(false);
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
setNewExtraData({});
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
@@ -117,25 +280,65 @@ 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;
|
||||
// Finalize on the single recorded weight (external clients weighed once).
|
||||
// Like handleMeasure, the screen updates from the server's SSE broadcast.
|
||||
const handleOneWeight = async () => {
|
||||
if (!selectedVehicle || isOneWeighing) return;
|
||||
if (!window.confirm('Finalize with a single weight?')) return;
|
||||
setIsOneWeighing(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}/one-weight/`, {
|
||||
station_key: stationKey,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to finalize with a single weight');
|
||||
} finally {
|
||||
setIsOneWeighing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Record a weighing entered by hand - requires a note
|
||||
const handleManualMeasure = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
const value = parseInt(manualForm.value, 10);
|
||||
if (isNaN(value)) {
|
||||
setError('Enter a valid weight');
|
||||
return;
|
||||
}
|
||||
if (!manualForm.note.trim()) {
|
||||
setError('A note is required for a manual measurement');
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
|
||||
value,
|
||||
manual: true,
|
||||
note: manualForm.note.trim(),
|
||||
station_key: stationKey,
|
||||
});
|
||||
setShowManualForm(false);
|
||||
setManualForm({ value: '', note: '' });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to record measurement');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,7 +349,7 @@ export default function Main() {
|
||||
setError('');
|
||||
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 +359,26 @@ export default function Main() {
|
||||
}
|
||||
};
|
||||
|
||||
// Apply a saved combination - merge so non-lookup field values are preserved
|
||||
// A combination carries its selector value, so applying one must also drop
|
||||
// whatever the previous selection revealed - otherwise a stale field survives
|
||||
// the merge and the payload contradicts the new source.
|
||||
const applyCombination = (setter) => (data) => {
|
||||
setter(prev => {
|
||||
const next = { ...prev, ...data };
|
||||
for (const [code, nom] of Object.entries(definitions)) {
|
||||
if (nom.kind !== 'selector') continue;
|
||||
if (!(code in data) || data[code] === prev[code]) continue;
|
||||
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
|
||||
if (!(revealed in data)) delete next[revealed];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleApplyCombination = applyCombination(setExtraData);
|
||||
const handleApplyCombinationNew = applyCombination(setNewExtraData);
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
@@ -163,39 +386,69 @@ export default function Main() {
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Net weight
|
||||
const netWeight = (selectedVehicle?.tare != null && selectedVehicle?.gross != null)
|
||||
? selectedVehicle.gross - selectedVehicle.tare
|
||||
: null;
|
||||
// Net weight - the server's net when set (a one-weight vehicle has no tare,
|
||||
// so gross - tare cannot produce it), otherwise derived from both weighings.
|
||||
const netWeight = vehicleNetWeight(selectedVehicle);
|
||||
|
||||
// Vehicle data shaped for report preview/design
|
||||
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
|
||||
const vehicleReportData = useMemo(() => {
|
||||
if (!selectedVehicle) return null;
|
||||
const rawExtra = selectedVehicle.extra?.data || {};
|
||||
const extra = {};
|
||||
for (const [code, value] of Object.entries(rawExtra)) {
|
||||
const def = definitions[code];
|
||||
if (!def) continue;
|
||||
if (def.kind === 'lookup') {
|
||||
const entry = (entries[code] || []).find(e => e.id === value);
|
||||
extra[def.name] = entry?.display_value ?? '';
|
||||
} else {
|
||||
extra[def.name] = value ?? '';
|
||||
const canMeasure = !!currentUser?.can_measure;
|
||||
// doc_number is assigned on finalization by both the second weighing and
|
||||
// the one-weight path, so it is the one signal that covers both.
|
||||
const isFinalized = selectedVehicle?.doc_number != null;
|
||||
|
||||
// Weighed exactly once and still open - may be closed on that single weight.
|
||||
const canOneWeight = canMeasure && !isFinalized && !selectedVehicle?.is_void
|
||||
&& (selectedVehicle?.tare != null) !== (selectedVehicle?.gross != null);
|
||||
|
||||
// Vehicle data shaped for report preview/design (shared with History)
|
||||
const vehicleReportData = useMemo(
|
||||
() => buildVehicleReportData(selectedVehicle, definitions, entries),
|
||||
[selectedVehicle, 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 always opens the unified preview modal now — the modal
|
||||
// itself fetches both the matrix and a4 notice_report variants and decides
|
||||
// what Print/Download do based on whichever kind is selected in its own
|
||||
// switcher. Previously an a4 report skipped the modal and went straight to
|
||||
// window.print(); that bypass is gone so both kinds get the same
|
||||
// preview/print/download/mark-notice-printed flow.
|
||||
const handlePrintNotice = async () => {
|
||||
if (isPrinting) return;
|
||||
setIsPrinting(true);
|
||||
try {
|
||||
const report = await fetchNoticeReport();
|
||||
if (!report) {
|
||||
setError('Report "notice_report" not found. Create it in the Report Editor first.');
|
||||
return;
|
||||
}
|
||||
setShowNoticeReport(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to print notice:', err);
|
||||
setError('Failed to load the notice report');
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
}
|
||||
return {
|
||||
vehicle: {
|
||||
...selectedVehicle,
|
||||
net: netWeight,
|
||||
extra,
|
||||
}
|
||||
};
|
||||
}, [selectedVehicle, netWeight, definitions, entries]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<div className="main-page">
|
||||
<div className="main">
|
||||
{/* Left panel - Vehicle list */}
|
||||
<div className="main-left">
|
||||
@@ -215,20 +468,46 @@ export default function Main() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vehicle-list">
|
||||
{filteredVehicles.map(v => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vehicle-list-item ${selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''}`}
|
||||
onClick={() => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); }}
|
||||
>
|
||||
<span className="vehicle-list-number">{v.vehicle_number}</span>
|
||||
{v.tare != null && (
|
||||
<span className="vehicle-list-weight">{v.tare} kg</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredVehicles.length === 0 && (
|
||||
<div
|
||||
className="vehicle-list"
|
||||
style={listStagingStyle}
|
||||
onPointerEnter={handleListPointerOver}
|
||||
onPointerDown={trackListPointer}
|
||||
onPointerMove={handleListPointerOver}
|
||||
onPointerLeave={handleListPointerLeave}
|
||||
>
|
||||
{filteredVehicles.map(({ id, item: v, phase }) => {
|
||||
if (phase === 'pending' || phase === 'distorting') {
|
||||
return <PendingMarker key={id} reduceMotion={reduceMotion} distorting={phase === 'distorting'} />;
|
||||
}
|
||||
const isGone = phase === 'leaving' || phase === 'exiting';
|
||||
const isEntering = phase === 'entering';
|
||||
// Only settled rows are clickable: an opening row is
|
||||
// still changing size under the cursor.
|
||||
const isInteractive = phase === 'stable';
|
||||
const stateClass = {
|
||||
entering: 'vehicle-list-item--entering',
|
||||
leaving: 'vehicle-list-item--leaving',
|
||||
exiting: 'vehicle-list-item--leaving vehicle-list-item--exiting',
|
||||
}[phase] || '';
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`vehicle-list-item ${!isGone && selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''} ${stateClass}`}
|
||||
ref={isEntering ? measureOpeningRow : undefined}
|
||||
aria-disabled={!isInteractive || undefined}
|
||||
onClick={isInteractive ? () => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); releaseListHoldOnClick(); } : undefined}
|
||||
>
|
||||
{isEntering && !reduceMotion
|
||||
? <RevealPlate text={v.vehicle_number} />
|
||||
: <span className="vehicle-list-number">{v.vehicle_number}</span>}
|
||||
{v.tare != null && (
|
||||
<span className="vehicle-list-weight">{v.tare} kg</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!filteredVehicles.some(e => e.phase !== 'pending' && e.phase !== 'distorting') && (
|
||||
<div className="vehicle-list-empty">No vehicles found</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -243,7 +522,7 @@ export default function Main() {
|
||||
<h2 className="vehicle-detail-title">New Vehicle</h2>
|
||||
|
||||
<div className="vehicle-form-section">
|
||||
<div className="extra-fields">
|
||||
<div className="vehicle-new-fields">
|
||||
<div className="extra-field">
|
||||
<label>Vehicle Number *</label>
|
||||
<input
|
||||
@@ -281,22 +560,32 @@ export default function Main() {
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="extra-field">
|
||||
<label>Driver Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newForm.driver_name}
|
||||
onChange={e => setNewForm(prev => ({ ...prev, driver_name: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombinationNew}
|
||||
/>
|
||||
<h3>Additional Data</h3>
|
||||
<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>
|
||||
@@ -316,7 +605,7 @@ export default function Main() {
|
||||
<button
|
||||
className="extra-save-btn"
|
||||
onClick={handleCreateVehicle}
|
||||
disabled={isSaving || !newForm.vehicle_number.trim()}
|
||||
disabled={isSaving || !newForm.vehicle_number.trim() || !newForm.driver_name.trim()}
|
||||
>
|
||||
{isSaving ? 'Creating...' : 'Create Vehicle'}
|
||||
</button>
|
||||
@@ -330,103 +619,218 @@ export default function Main() {
|
||||
<div className="vehicle-detail">
|
||||
{error && <div className="vehicle-error">{error}</div>}
|
||||
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
|
||||
{/* Current reading */}
|
||||
<div className="reading-display">
|
||||
<label>Current Reading</label>
|
||||
<div className={`reading-value ${isConnected ? '' : 'reading-value--disconnected'}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||
}
|
||||
{/* Header bar: icon, plate number, derived status pills, and (cockpit
|
||||
only) a photo. Both pills come from real state, not decoration:
|
||||
"on scale" is this vehicle having a live reading right now, and
|
||||
the weighing status is the same isFinalized used by the cards
|
||||
below. */}
|
||||
<div className="vehicle-hero">
|
||||
<div className="vehicle-heading">
|
||||
<span className="vehicle-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.7"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h11v11H3z" />
|
||||
<path d="M14 10h4l3 3v4h-7z" />
|
||||
<circle cx="7" cy="19" r="2" />
|
||||
<circle cx="18" cy="19" r="2" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
<span className={`vehicle-status ${isFinalized ? 'vehicle-status--done' : 'vehicle-status--progress'}`}>
|
||||
● {isFinalized ? 'Finalized' : 'In progress'}
|
||||
</span>
|
||||
{isConnected && currentWeight !== null && !isNaN(currentWeight) && (
|
||||
<span className="vehicle-status vehicle-status--scale">◉ On Scale</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Equalizer: last 40 live readings, oldest to newest left to
|
||||
right. Green when the last reading was stable, rose when it
|
||||
wasn't. The corner button switches between scaling against a
|
||||
fixed 60t ceiling (comparable across vehicles) and scaling to
|
||||
whatever is on screen right now (fills the strip for any
|
||||
load). No border/background of its own - it sits directly on
|
||||
the hero bar between the pills and the photo. */}
|
||||
<div className="vehicle-hero-equalizer">
|
||||
<button
|
||||
type="button"
|
||||
className="equalizer-scale-toggle"
|
||||
onClick={() => setEqualizerScaleMode(m => m === 'fixed' ? 'auto' : 'fixed')}
|
||||
title={equalizerScaleMode === 'fixed'
|
||||
? 'Scaled to 60,000 kg - click for auto-scale'
|
||||
: 'Auto-scaled to visible readings - click for fixed 60,000 kg'}
|
||||
>
|
||||
{equalizerScaleMode === 'fixed' ? '60t' : 'AUTO'}
|
||||
</button>
|
||||
<div className="equalizer-bars">
|
||||
{equalizerSlots.map((slot, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`equalizer-bar ${
|
||||
slot.balanced === null ? '' : slot.balanced ? 'equalizer-bar--balanced' : 'equalizer-bar--unbalanced'
|
||||
}`}
|
||||
style={{ height: `${slot.heightPct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vehicle-hero-photo" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Weighing section */}
|
||||
{/* Weighing section - four readouts plus an action column, all of
|
||||
which always render, so the row never changes its column count
|
||||
as a weighing progresses. The readouts hold no controls: every
|
||||
button lives in the action column. */}
|
||||
<div className="weighing-section">
|
||||
{/* Tare */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'}
|
||||
{/* Current reading, which doubles as the Measure card:
|
||||
both showed the same live weight. Icon and text sit in
|
||||
separate columns (weight-card-icon / -content), matching
|
||||
the reference layout - the icon spans the card's full
|
||||
height instead of sitting inline with the label. */}
|
||||
<div className="weight-card weight-card--reading">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Current Reading</div>
|
||||
<div className={`weight-card-value weight-card-value--reading ${
|
||||
!isConnected
|
||||
? 'reading-value--disconnected'
|
||||
: currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||
}
|
||||
</div>
|
||||
{isConnected && readingError && latestReading && (
|
||||
<div className="reading-error" title={latestReading.data}>
|
||||
{readingError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedVehicle.tare_date && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.tare_date)}
|
||||
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
|
||||
{/* Measure/Manual entry, moved here from the action column:
|
||||
they act on THIS card's live value, so they sit beside it. */}
|
||||
{canMeasure && !isFinalized && (
|
||||
<div className="weight-card-reading-actions">
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
Measure
|
||||
</button>
|
||||
{!licenseAllowed && (
|
||||
<div className="weight-license-block">{licenseDetail}</div>
|
||||
)}
|
||||
{currentUser?.can_manually_measure && (
|
||||
<button
|
||||
className="weight-manual-btn"
|
||||
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
|
||||
>
|
||||
Manual entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleSetTare}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.tare != null}
|
||||
>
|
||||
Set Tare
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tare. A lone reading is parked here by the backend, but a
|
||||
lighter second one moves it to Gross - the slot is shown
|
||||
plainly rather than hedged behind a neutral label. */}
|
||||
<div className="weight-card weight-card--tare">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '—'}
|
||||
</div>
|
||||
{selectedVehicle.tare != null && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Gross */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'}
|
||||
</div>
|
||||
{selectedVehicle.gross_date && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.gross_date)}
|
||||
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
|
||||
<div className="weight-card weight-card--gross">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '—'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleSetGross}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.gross != null}
|
||||
>
|
||||
Set Gross
|
||||
</button>
|
||||
{selectedVehicle.gross != null && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Net */}
|
||||
{netWeight !== null && (
|
||||
<div className="weight-card weight-card--net">
|
||||
<div className="weight-card weight-card--net">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Net</div>
|
||||
<div className="weight-card-value">{netWeight} kg</div>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={() => setShowNoticeReport(true)}
|
||||
>
|
||||
Print Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={() => navigate('/report-editor', {
|
||||
state: {
|
||||
reportName: 'notice_report',
|
||||
vehicleData: vehicleReportData,
|
||||
}
|
||||
})}
|
||||
>
|
||||
Design Notice
|
||||
</button>
|
||||
<div className="weight-card-value">
|
||||
{netWeight !== null ? `${netWeight} kg` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action column. Measure/Manual entry live in the Current
|
||||
Reading card, since they act on that card's live value. Here:
|
||||
One weight only after a single weighing, Print Notice once
|
||||
finalized; otherwise empty (just its panel chrome). */}
|
||||
<div className="weight-card weight-actions">
|
||||
{canOneWeight && (
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleOneWeight}
|
||||
disabled={isOneWeighing || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
{isOneWeighing ? 'Finalizing...' : 'One weight only'}
|
||||
</button>
|
||||
)}
|
||||
{isFinalized && netWeight !== null && (
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handlePrintNotice}
|
||||
onMouseEnter={prefetchA4Print}
|
||||
onFocus={prefetchA4Print}
|
||||
disabled={isPrinting}
|
||||
>
|
||||
{isPrinting ? 'Preparing...' : 'Print Notice'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra fields from nomenclatures */}
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombination}
|
||||
/>
|
||||
<h3>Additional Data</h3>
|
||||
<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 +854,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
/* App shell: the page content takes everything left of the rail and exactly
|
||||
the viewport height. The content column scrolls on its own, so no screen can
|
||||
produce a page (body) scrollbar; a screen that must fit without scrolling
|
||||
(Main) sizes itself to this column with flex instead of calc(100vh - x). */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The rail is the old header turned on its side, so it keeps the header's
|
||||
surface tokens. */
|
||||
.nav-rail {
|
||||
--rail-width: 68px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
width: var(--rail-width);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.nav-rail-top,
|
||||
.nav-rail-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-rail-bottom {
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
/* Positioning context for a flyout opening beside its trigger. */
|
||||
.nav-rail-anchor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-rail-item {
|
||||
width: 60px;
|
||||
padding: var(--space-1-5) var(--space-0-5) var(--space-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-0-5);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
color: var(--header-text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-rail-item:hover {
|
||||
background: var(--header-chrome);
|
||||
border-color: var(--header-chrome-border);
|
||||
}
|
||||
|
||||
.nav-rail-icon {
|
||||
font-size: var(--fs-xl);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.nav-rail-label {
|
||||
max-width: 100%;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The lit edge faces the content, the way a tab points at its page. */
|
||||
.nav-rail-item--active,
|
||||
.nav-rail-item--active:hover {
|
||||
background: var(--header-chrome-hover);
|
||||
border-color: var(--header-chrome-border);
|
||||
box-shadow: inset 3px 0 0 var(--header-active);
|
||||
}
|
||||
|
||||
.nav-rail-item:focus-visible,
|
||||
.nav-rail-theme:focus-visible,
|
||||
.nav-rail-avatar:focus-visible {
|
||||
outline: 2px solid var(--header-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-rail-theme {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--header-chrome);
|
||||
border: 1px solid var(--header-chrome-border);
|
||||
color: var(--header-text);
|
||||
font-size: var(--fs-xl);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-rail-theme:hover {
|
||||
background: var(--header-chrome-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.nav-rail-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: var(--header-avatar-bg);
|
||||
color: var(--header-avatar-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: inherit;
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-lg);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.nav-rail-avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
/* Over the page, under the flyout: any outside click closes it. */
|
||||
.nav-rail-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Flyouts open to the LEFT of the rail, over the page content. */
|
||||
.nav-rail-flyout {
|
||||
position: absolute;
|
||||
right: calc(100% + var(--space-2-5));
|
||||
z-index: 2;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-rail-menu {
|
||||
top: 0;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* The avatar sits at the foot of the rail, so its menu grows upwards. */
|
||||
.nav-rail-user-menu {
|
||||
bottom: 0;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.nav-rail-flyout button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.nav-rail-flyout button + button,
|
||||
.nav-rail-user + button {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-rail-flyout button:hover,
|
||||
.nav-rail-flyout button:focus-visible {
|
||||
background: var(--surface-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav-rail-menu strong,
|
||||
.nav-rail-user strong {
|
||||
display: block;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.nav-rail-menu span,
|
||||
.nav-rail-user span {
|
||||
display: block;
|
||||
margin-top: var(--space-0-5);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-rail-user {
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
/* Licence warning: amber while in grace, red once measuring is blocked. The
|
||||
colour drives the label, the "!" marker and the badge; an emoji icon keeps
|
||||
its own colours, so the item also gets a tinted surface. */
|
||||
.nav-rail-item--license-grace {
|
||||
--license-tone: var(--warning);
|
||||
--license-tone-soft: var(--warning-soft);
|
||||
}
|
||||
|
||||
.nav-rail-item--license-blocked {
|
||||
--license-tone: var(--danger);
|
||||
--license-tone-soft: var(--danger-soft);
|
||||
}
|
||||
|
||||
.nav-rail-item--license {
|
||||
color: var(--license-tone);
|
||||
background: var(--license-tone-soft);
|
||||
}
|
||||
|
||||
.nav-rail-item--license:hover {
|
||||
background: var(--license-tone-soft);
|
||||
border-color: var(--license-tone);
|
||||
}
|
||||
|
||||
.nav-rail-item--license .nav-rail-icon {
|
||||
position: relative;
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.nav-rail-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--license-tone);
|
||||
box-shadow: 0 0 0 2px var(--header-bg);
|
||||
}
|
||||
|
||||
/* One gentle pulse when the badge first appears, never on a loop. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.nav-rail-badge {
|
||||
animation: nav-rail-badge-pulse 1.2s ease-out 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nav-rail-badge-pulse {
|
||||
0% { transform: scale(1); }
|
||||
35% { transform: scale(1.6); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.nav-rail-license-popover {
|
||||
top: 0;
|
||||
width: 260px;
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.nav-rail-license-popover--blocked {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The non-admin marker sits near the foot of the rail: grow upwards. */
|
||||
.nav-rail-license-popover--bottom {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.nav-rail-license-popover p {
|
||||
margin: 0;
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nav-rail-license-popover strong {
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.nav-rail-license-popover button {
|
||||
border-top: 1px solid var(--border);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
/* Cockpit: the rail needs its own lit edge facing the content, or it floats,
|
||||
and the active item glows like the rest of the lit instruments. */
|
||||
[data-theme="cockpit"] .nav-rail {
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -1px 0 0 color-mix(in srgb, var(--accent) 25%, transparent),
|
||||
var(--shadow-raised);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail-item--active {
|
||||
color: var(--accent);
|
||||
box-shadow: inset 3px 0 0 var(--accent), var(--glow-accent);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail-flyout {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail-license-popover {
|
||||
border-left-color: var(--warning);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail-license-popover--blocked {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail-item--license-grace .nav-rail-badge {
|
||||
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-warning);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail-item--license-blocked .nav-rail-badge {
|
||||
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-danger);
|
||||
}
|
||||
|
||||
/* Short windows: drop the labels before the rail runs out of height. */
|
||||
@media (max-height: 620px) and (min-width: 700px) {
|
||||
.nav-rail-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-rail-item {
|
||||
padding: var(--space-1-5) var(--space-0-5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Narrow screens: the rail becomes a bottom bar, icons only. */
|
||||
@media (max-width: 699px) {
|
||||
.app-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-rail {
|
||||
width: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
[data-theme="cockpit"] .nav-rail {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-rail-top,
|
||||
.nav-rail-bottom {
|
||||
flex-direction: row;
|
||||
gap: var(--space-0-5);
|
||||
}
|
||||
|
||||
.nav-rail-top {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.nav-rail-item {
|
||||
width: auto;
|
||||
min-width: 36px;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-rail-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-rail-item--active,
|
||||
.nav-rail-item--active:hover,
|
||||
[data-theme="cockpit"] .nav-rail-item--active {
|
||||
box-shadow: inset 0 -3px 0 var(--header-active);
|
||||
}
|
||||
|
||||
.nav-rail-theme,
|
||||
.nav-rail-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
/* Above the bar instead of beside it. */
|
||||
.nav-rail-flyout {
|
||||
right: auto;
|
||||
left: 0;
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
}
|
||||
|
||||
.nav-rail-user-menu,
|
||||
.nav-rail-license-popover {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-rail-license-popover {
|
||||
max-width: calc(100vw - 2 * var(--space-4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import React, { useState, useEffect, useRef, useId } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLicense } from '../contexts/LicenseContext';
|
||||
import { getLicenseWarning } from './licenseWarning';
|
||||
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
||||
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
|
||||
import './NavRail.css';
|
||||
|
||||
function RailItem({ icon, label, title, onClick, isActive = false, buttonRef, className = '', noTooltip = false, ...rest }) {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`nav-rail-item${isActive ? ' nav-rail-item--active' : ''}${className ? ` ${className}` : ''}`}
|
||||
onClick={onClick}
|
||||
title={noTooltip ? undefined : title}
|
||||
aria-label={title}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
{...rest}
|
||||
>
|
||||
<span className="nav-rail-icon" aria-hidden="true">{icon}</span>
|
||||
<span className="nav-rail-label" aria-hidden="true">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const LICENSE_OPEN_DELAY_MS = 200;
|
||||
const LICENSE_CLOSE_DELAY_MS = 250;
|
||||
|
||||
// Licence item in its warning state, with a popover beside the rail that opens
|
||||
// on hover (after a short delay), focus or click. Admins get the real Licence
|
||||
// item, which still navigates; everyone else gets a "!" marker that does not.
|
||||
function LicenseWarning({ warning, isAdmin, isActive, onOpenPage }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const skipFocusOpenRef = useRef(false);
|
||||
const popoverId = useId();
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const handleMouseDown = (e) => {
|
||||
if (!anchorRef.current?.contains(e.target)) setIsOpen(false);
|
||||
};
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
clearTimeout(timerRef.current);
|
||||
setIsOpen(false);
|
||||
// Only pull focus back when it was inside the popover itself.
|
||||
const active = document.activeElement;
|
||||
if (active !== triggerRef.current && anchorRef.current?.contains(active)) {
|
||||
skipFocusOpenRef.current = true;
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const openNow = () => {
|
||||
clearTimeout(timerRef.current);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Entering again (e.g. trigger -> popover across the gap) cancels a pending close.
|
||||
const handleMouseEnter = () => {
|
||||
clearTimeout(timerRef.current);
|
||||
if (!isOpen) timerRef.current = setTimeout(() => setIsOpen(true), LICENSE_OPEN_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setIsOpen(false), LICENSE_CLOSE_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (skipFocusOpenRef.current) {
|
||||
skipFocusOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
openNow();
|
||||
};
|
||||
|
||||
// A null relatedTarget is a click on something unfocusable; the outside
|
||||
// mousedown handler covers the case where that was outside the anchor.
|
||||
const handleBlur = (e) => {
|
||||
if (e.relatedTarget && !anchorRef.current?.contains(e.relatedTarget)) {
|
||||
clearTimeout(timerRef.current);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPage = () => {
|
||||
setIsOpen(false);
|
||||
onOpenPage();
|
||||
};
|
||||
|
||||
const badgeIcon = (icon) => (
|
||||
<>
|
||||
{icon}
|
||||
<span className="nav-rail-badge" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={anchorRef}
|
||||
className="nav-rail-anchor"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<RailItem
|
||||
buttonRef={triggerRef}
|
||||
className={`nav-rail-item--license nav-rail-item--license-${warning.level}`}
|
||||
icon={badgeIcon(isAdmin ? '🔑' : '!')}
|
||||
label="Licence"
|
||||
title={`Licence: ${warning.message}`}
|
||||
noTooltip /* the popover already shows the message; aria-label keeps it for screen readers */
|
||||
onClick={isAdmin ? onOpenPage : openNow}
|
||||
onFocus={handleFocus}
|
||||
isActive={isActive}
|
||||
aria-describedby={isOpen ? popoverId : undefined}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? popoverId : undefined}
|
||||
/>
|
||||
{isOpen && (
|
||||
<div
|
||||
id={popoverId}
|
||||
className={`nav-rail-flyout nav-rail-license-popover nav-rail-license-popover--${warning.level}${isAdmin ? '' : ' nav-rail-license-popover--bottom'}`}
|
||||
role={isAdmin ? 'dialog' : 'tooltip'}
|
||||
aria-label={isAdmin ? 'Licence warning' : undefined}
|
||||
>
|
||||
<p>
|
||||
{warning.lead}
|
||||
{warning.emphasis && <strong>{warning.emphasis}</strong>}
|
||||
{warning.tail}
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<button onClick={handleOpenPage}>Open licence page</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getInitials = (user) => {
|
||||
if (user.first_name && user.last_name) {
|
||||
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
|
||||
}
|
||||
return (user.username || '').substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
function NavRail() {
|
||||
const { currentUser, logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { status: licenseStatus } = useLicense();
|
||||
const licenseWarning = getLicenseWarning(licenseStatus);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
|
||||
const [showEditorMenu, setShowEditorMenu] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const editorButtonRef = useRef(null);
|
||||
const avatarButtonRef = useRef(null);
|
||||
|
||||
// Escape closes whichever flyout is open and hands focus back to its trigger.
|
||||
useEffect(() => {
|
||||
if (!showEditorMenu && !showUserMenu) return undefined;
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const trigger = showEditorMenu ? editorButtonRef.current : avatarButtonRef.current;
|
||||
setShowEditorMenu(false);
|
||||
setShowUserMenu(false);
|
||||
trigger?.focus();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showEditorMenu, showUserMenu]);
|
||||
|
||||
const openEditor = (route) => {
|
||||
setShowEditorMenu(false);
|
||||
navigate(route);
|
||||
};
|
||||
|
||||
const handleChangePassword = () => {
|
||||
setShowUserMenu(false);
|
||||
setShowPasswordOverlay(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setShowUserMenu(false);
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="nav-rail" aria-label="Main navigation">
|
||||
<div className="nav-rail-top">
|
||||
<RailItem
|
||||
icon="⚖️"
|
||||
label="Weighing"
|
||||
title="Weighing"
|
||||
onClick={() => navigate('/')}
|
||||
isActive={pathname === '/'}
|
||||
/>
|
||||
{currentUser?.can_edit_documents && (
|
||||
<div className="nav-rail-anchor">
|
||||
<RailItem
|
||||
buttonRef={editorButtonRef}
|
||||
icon="📝"
|
||||
label="Reports"
|
||||
title="Report Editor"
|
||||
onClick={() => setShowEditorMenu(v => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showEditorMenu}
|
||||
/>
|
||||
{showEditorMenu && (
|
||||
<>
|
||||
{/* Click-away close, without a document-level listener. */}
|
||||
<div
|
||||
className="nav-rail-backdrop"
|
||||
onClick={() => setShowEditorMenu(false)}
|
||||
/>
|
||||
<div className="nav-rail-flyout nav-rail-menu" role="menu">
|
||||
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{currentUser?.can_manage_entities && (
|
||||
<RailItem
|
||||
icon="📋"
|
||||
label="Lists"
|
||||
title="Nomenclatures"
|
||||
onClick={() => setShowNomenclatureManager(true)}
|
||||
/>
|
||||
)}
|
||||
<RailItem
|
||||
icon="🕘"
|
||||
label="History"
|
||||
title="History"
|
||||
onClick={() => navigate('/history')}
|
||||
isActive={pathname === '/history'}
|
||||
/>
|
||||
{currentUser?.is_admin && (
|
||||
<>
|
||||
<RailItem
|
||||
icon="👥"
|
||||
label="Users"
|
||||
title="User Manager"
|
||||
onClick={() => navigate('/users')}
|
||||
isActive={pathname === '/users'}
|
||||
/>
|
||||
<RailItem
|
||||
icon="📡"
|
||||
label="Scale"
|
||||
title="Scale Monitor"
|
||||
onClick={() => navigate('/scale-monitor')}
|
||||
isActive={pathname === '/scale-monitor'}
|
||||
/>
|
||||
{licenseWarning ? (
|
||||
<LicenseWarning
|
||||
warning={licenseWarning}
|
||||
isAdmin
|
||||
isActive={pathname === '/license'}
|
||||
onOpenPage={() => navigate('/license')}
|
||||
/>
|
||||
) : (
|
||||
<RailItem
|
||||
icon="🔑"
|
||||
label="Licence"
|
||||
title="Licence"
|
||||
onClick={() => navigate('/license')}
|
||||
isActive={pathname === '/license'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nav-rail-bottom">
|
||||
{licenseWarning && !currentUser?.is_admin && (
|
||||
<LicenseWarning warning={licenseWarning} isAdmin={false} />
|
||||
)}
|
||||
<button
|
||||
className="nav-rail-theme"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'cockpit' ? 'Switch to the classic theme' : 'Switch to the cockpit theme'}
|
||||
aria-label="Toggle colour theme"
|
||||
>
|
||||
{theme === 'cockpit' ? '☀' : '◐'}
|
||||
</button>
|
||||
<div className="nav-rail-anchor">
|
||||
<button
|
||||
ref={avatarButtonRef}
|
||||
className="nav-rail-avatar"
|
||||
onClick={() => setShowUserMenu(v => !v)}
|
||||
title={currentUser?.username}
|
||||
aria-label={`Account: ${currentUser?.username ?? ''}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showUserMenu}
|
||||
>
|
||||
{getInitials(currentUser || {})}
|
||||
</button>
|
||||
{showUserMenu && (
|
||||
<>
|
||||
<div
|
||||
className="nav-rail-backdrop"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
<div className="nav-rail-flyout nav-rail-user-menu" role="menu">
|
||||
<div className="nav-rail-user">
|
||||
<strong>{currentUser?.username}</strong>
|
||||
<span>{currentUser?.role}</span>
|
||||
</div>
|
||||
<button role="menuitem" onClick={handleChangePassword}>Change password</button>
|
||||
<button role="menuitem" onClick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{showPasswordOverlay && (
|
||||
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
|
||||
)}
|
||||
|
||||
{showNomenclatureManager && (
|
||||
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavRail;
|
||||
@@ -5,12 +5,12 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
padding: var(--space-5);
|
||||
animation: nmFadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
/* Main content container */
|
||||
.nm-content {
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
max-height: 90vh;
|
||||
@@ -39,8 +39,8 @@
|
||||
|
||||
/* Header */
|
||||
.nm-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -49,15 +49,15 @@
|
||||
|
||||
.nm-header h2 {
|
||||
margin: 0;
|
||||
color: #667eea;
|
||||
font-size: 22px;
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-2xl);
|
||||
}
|
||||
|
||||
.nm-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
font-size: var(--fs-display);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
@@ -70,8 +70,8 @@
|
||||
}
|
||||
|
||||
.nm-close-button:hover {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Body - two panels */
|
||||
@@ -85,28 +85,28 @@
|
||||
/* Left panel */
|
||||
.nm-list-panel {
|
||||
width: 250px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-add-btn {
|
||||
margin: 12px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
margin: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--accent-gradient);
|
||||
color: var(--accent-contrast);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.nm-add-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 3px 8px var(--accent-soft);
|
||||
}
|
||||
|
||||
.nm-list {
|
||||
@@ -117,19 +117,29 @@
|
||||
.nm-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nm-list-item:hover {
|
||||
background: #f5f5ff;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.nm-list-item--active {
|
||||
background: #eef0ff;
|
||||
border-left: 3px solid #667eea;
|
||||
background: var(--accent-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.nm-list-item--dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Inset shadow rather than a border, so the drop line adds no row height */
|
||||
.nm-list-item--drag-over {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.nm-list-item-info {
|
||||
@@ -139,9 +149,9 @@
|
||||
|
||||
.nm-list-item-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: var(--fw-semibold);
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -149,40 +159,40 @@
|
||||
|
||||
.nm-list-item-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-0-5);
|
||||
}
|
||||
|
||||
.nm-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-lg);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: var(--space-1);
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-edit-btn:hover {
|
||||
color: #667eea;
|
||||
background: #f0f0ff;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.nm-loading {
|
||||
padding: 20px;
|
||||
padding: var(--space-5);
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.nm-empty {
|
||||
padding: 16px;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
@@ -197,51 +207,51 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 15px;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-lg);
|
||||
padding: var(--space-10);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nm-form {
|
||||
padding: 20px 24px;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
}
|
||||
|
||||
.nm-form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.nm-error {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fcc;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--danger);
|
||||
font-size: var(--fs-base);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Form grid */
|
||||
.nm-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.nm-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nm-form-group label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -249,61 +259,61 @@
|
||||
.nm-form-group input[type="text"],
|
||||
.nm-form-group input[type="number"],
|
||||
.nm-form-group select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-size: var(--fs-md);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.nm-form-group input:focus,
|
||||
.nm-form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nm-form-group input:disabled,
|
||||
.nm-form-group select:disabled {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--surface-sunken);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.nm-section {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.nm-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: var(--space-2);
|
||||
padding-bottom: var(--space-1-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nm-section-header h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--accent);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.nm-section-add {
|
||||
background: none;
|
||||
border: 1px solid #667eea;
|
||||
color: #667eea;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-section-add:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.nm-section-add:disabled {
|
||||
@@ -313,7 +323,7 @@
|
||||
|
||||
/* Fields table */
|
||||
.nm-fields-table {
|
||||
border: 1px solid #e0e0e0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -321,12 +331,12 @@
|
||||
.nm-fields-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #f8f8fc;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
gap: var(--space-1-5);
|
||||
padding: var(--space-1-5) var(--space-2);
|
||||
background: var(--surface-sunken);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -334,38 +344,38 @@
|
||||
.nm-fields-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 2fr 1.2fr 40px 32px;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
gap: var(--space-1-5);
|
||||
padding: var(--space-1-5) var(--space-2);
|
||||
border-top: 1px solid var(--border);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nm-fields-row input[type="text"] {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-1-5) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-base);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nm-fields-row input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nm-fields-row select {
|
||||
padding: 5px 4px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-1-5) var(--space-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-base);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nm-fields-row select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nm-fields-row input[type="checkbox"] {
|
||||
@@ -378,8 +388,8 @@
|
||||
.nm-remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ccc;
|
||||
font-size: 20px;
|
||||
color: var(--border-strong);
|
||||
font-size: var(--fs-2xl);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
@@ -392,8 +402,8 @@
|
||||
}
|
||||
|
||||
.nm-remove-btn:hover:not(:disabled) {
|
||||
color: #c33;
|
||||
background: #fee;
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.nm-remove-btn:disabled {
|
||||
@@ -405,51 +415,51 @@
|
||||
.nm-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nm-entry {
|
||||
border: 1px solid #e0e0e0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.nm-entry--inactive {
|
||||
opacity: 0.5;
|
||||
background: #fafafa;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.nm-entry-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nm-entry-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--space-0-5);
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nm-entry-field label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.nm-entry-field input[type="text"],
|
||||
.nm-entry-field input[type="number"],
|
||||
.nm-entry-field select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-1-5) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-base);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -457,61 +467,61 @@
|
||||
.nm-entry-field input:focus,
|
||||
.nm-entry-field select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nm-entry-field input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 4px;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.nm-entry-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nm-entry-save {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nm-entry-save:hover {
|
||||
background: #5568d3;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.nm-entry-toggle {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nm-entry-toggle:hover {
|
||||
background: #c8e6c9;
|
||||
background: color-mix(in srgb, var(--success) 25%, transparent);
|
||||
}
|
||||
|
||||
.nm-entry-toggle--inactive {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.nm-entry-toggle--inactive:hover {
|
||||
background: #ffe0b2;
|
||||
background: color-mix(in srgb, var(--warning) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Form actions */
|
||||
@@ -519,49 +529,49 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nm-form-actions-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: var(--space-2-5);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nm-cancel-btn {
|
||||
padding: 8px 20px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
color: #666;
|
||||
padding: var(--space-2) var(--space-5);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-cancel-btn:hover:not(:disabled) {
|
||||
border-color: #999;
|
||||
color: #333;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nm-save-btn {
|
||||
padding: 8px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: var(--space-2) var(--space-6);
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--accent-contrast);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.nm-save-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 3px 8px var(--accent-soft);
|
||||
}
|
||||
|
||||
.nm-save-btn:disabled,
|
||||
@@ -571,19 +581,19 @@
|
||||
}
|
||||
|
||||
.nm-delete-btn {
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border: 1px solid #e57373;
|
||||
color: #c33;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.nm-delete-btn:hover:not(:disabled) {
|
||||
background: #fee;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.nm-delete-btn:disabled {
|
||||
@@ -607,10 +617,85 @@
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nm-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Selector options editor --- */
|
||||
.nm-options-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-2-5) var(--space-2);
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nm-option-row {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.nm-option-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 32px;
|
||||
gap: var(--space-1-5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nm-option-head input[type="text"] {
|
||||
padding: var(--space-1-5) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: var(--fs-base);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nm-option-head input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nm-option-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-xl);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nm-option-remove:disabled {
|
||||
color: var(--border-strong);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nm-option-reveals {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2-5);
|
||||
margin-top: var(--space-1-5);
|
||||
padding-left: var(--space-0-5);
|
||||
}
|
||||
|
||||
.nm-option-reveals-label {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nm-option-reveal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--fs-base);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const EMPTY_FORM = {
|
||||
display_field: 'name',
|
||||
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"
|
||||
>
|
||||
×
|
||||
</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">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 13000;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
background-color: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -21,9 +21,9 @@
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: nuiFormSlideUp 0.15s ease-out;
|
||||
@@ -38,18 +38,18 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background-color: #f8f9fa;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--surface-sunken);
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-form-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nui-form-modal-close {
|
||||
@@ -60,8 +60,8 @@
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: #666;
|
||||
font-size: var(--fs-2xl);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
@@ -69,12 +69,12 @@
|
||||
}
|
||||
|
||||
.nui-form-modal-close:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #333;
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nui-form-modal-content {
|
||||
padding: 1.5rem;
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
.nui-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nui-dropdown-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -15,58 +15,58 @@
|
||||
.nui-dropdown-label--checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nui-dropdown-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nui-dropdown-select {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
font-size: var(--fs-md);
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nui-dropdown-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nui-dropdown-select:disabled {
|
||||
background-color: #f0f0f0;
|
||||
background-color: var(--surface-hover);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.nui-dropdown-input {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
font-size: var(--fs-md);
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nui-dropdown-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nui-dropdown-input:disabled {
|
||||
background-color: #f0f0f0;
|
||||
background-color: var(--surface-hover);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -94,11 +94,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
font-size: var(--fs-xl);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
@@ -107,9 +107,9 @@
|
||||
}
|
||||
|
||||
.nui-dropdown-manage-btn:hover:not(:disabled) {
|
||||
border-color: #667eea;
|
||||
color: #667eea;
|
||||
background-color: #f0f0ff;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.nui-dropdown-manage-btn:disabled {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
.nui-entry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.nui-entry-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nui-entry-form-field > label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.nui-entry-form-field input[type="text"],
|
||||
.nui-entry-form-field input[type="number"],
|
||||
.nui-entry-form-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
font-size: var(--fs-lg);
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -34,14 +34,14 @@
|
||||
.nui-entry-form-field input[type="number"]:focus,
|
||||
.nui-entry-form-field select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.nui-entry-form-field input[type="text"]:disabled,
|
||||
.nui-entry-form-field input[type="number"]:disabled,
|
||||
.nui-entry-form-field select:disabled {
|
||||
background-color: #f0f0f0;
|
||||
background-color: var(--surface-hover);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -58,23 +58,23 @@
|
||||
|
||||
/* Error state */
|
||||
.nui-entry-form-input-error {
|
||||
border-color: #dc3545 !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.nui-entry-form-error {
|
||||
font-size: 0.8rem;
|
||||
color: #dc3545;
|
||||
font-size: var(--fs-base);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Checkbox field */
|
||||
.nui-entry-form-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
font-weight: normal !important;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
font-weight: var(--fw-regular) !important;
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nui-entry-form-checkbox {
|
||||
@@ -86,27 +86,27 @@
|
||||
/* Actions */
|
||||
.nui-entry-form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nui-entry-form-cancel {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background-color: var(--text-muted);
|
||||
color: var(--text-inverse);
|
||||
padding: var(--space-2) var(--space-6);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.nui-entry-form-cancel:hover {
|
||||
background-color: #5a6268;
|
||||
background-color: var(--text);
|
||||
}
|
||||
|
||||
.nui-entry-form-cancel:active {
|
||||
@@ -114,19 +114,19 @@
|
||||
}
|
||||
|
||||
.nui-entry-form-save {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--accent-gradient);
|
||||
color: var(--accent-contrast);
|
||||
padding: var(--space-2) var(--space-6);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.nui-entry-form-save:hover:not(:disabled) {
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 3px 8px var(--accent-soft);
|
||||
}
|
||||
|
||||
.nui-entry-form-save:active:not(:disabled) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 12000;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
background-color: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -21,9 +21,9 @@
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: nuiMgmtSlideUp 0.15s ease-out;
|
||||
@@ -39,18 +39,18 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--surface-sunken);
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-mgmt-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nui-mgmt-close {
|
||||
@@ -61,8 +61,8 @@
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #666;
|
||||
font-size: var(--fs-2xl);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
@@ -70,60 +70,60 @@
|
||||
}
|
||||
|
||||
.nui-mgmt-close:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #333;
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.nui-mgmt-toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-mgmt-search {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ced4da;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-size: var(--fs-lg);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-search:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 0.2rem var(--accent-soft);
|
||||
}
|
||||
|
||||
.nui-mgmt-add-btn {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--success);
|
||||
color: var(--text-inverse);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nui-mgmt-add-btn:hover {
|
||||
background-color: #218838;
|
||||
background-color: color-mix(in srgb, var(--success) 85%, var(--text));
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.nui-mgmt-error {
|
||||
margin: 0.5rem 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
border: 1px solid #fcc;
|
||||
margin: var(--space-2) var(--space-6);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-size: var(--fs-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -141,17 +141,17 @@
|
||||
}
|
||||
|
||||
.nui-mgmt-table-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nui-mgmt-table-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nui-mgmt-table-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: var(--text);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
@@ -161,19 +161,19 @@
|
||||
}
|
||||
|
||||
.nui-mgmt-table thead {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--surface-sunken);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nui-mgmt-table th {
|
||||
padding: 0.6rem 1rem;
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-md);
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -195,16 +195,16 @@
|
||||
}
|
||||
|
||||
.nui-mgmt-table td {
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nui-mgmt-td-id {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.nui-mgmt-td-status {
|
||||
@@ -222,15 +222,15 @@
|
||||
}
|
||||
|
||||
.nui-mgmt-row:hover {
|
||||
background-color: #f0f0ff;
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.nui-mgmt-row--selected {
|
||||
background-color: #e8eaff;
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.nui-mgmt-row--selected:hover {
|
||||
background-color: #dcdeff;
|
||||
background-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.nui-mgmt-row--inactive {
|
||||
@@ -239,7 +239,7 @@
|
||||
|
||||
.nui-mgmt-row--inactive td {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nui-mgmt-row--inactive .nui-mgmt-td-id,
|
||||
@@ -251,56 +251,56 @@
|
||||
/* Active badge */
|
||||
.nui-mgmt-active-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--active {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
background-color: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--active:hover {
|
||||
background-color: #c3e6cb;
|
||||
background-color: color-mix(in srgb, var(--success) 25%, transparent);
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--inactive {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.nui-mgmt-active-badge--inactive:hover {
|
||||
background-color: #f5c6cb;
|
||||
background-color: color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
}
|
||||
|
||||
/* Edit button in row */
|
||||
.nui-mgmt-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nui-mgmt-edit-btn:hover {
|
||||
background-color: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #333;
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.nui-mgmt-empty {
|
||||
text-align: center;
|
||||
padding: 2rem !important;
|
||||
color: #999;
|
||||
padding: var(--space-8) !important;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -309,75 +309,75 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background-color: #f8f9fa;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--border);
|
||||
background-color: var(--surface-sunken);
|
||||
border-radius: 0 0 8px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-info {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid #667eea;
|
||||
color: #667eea;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
padding: var(--space-1-5) var(--space-4);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-footer-edit-btn:hover {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.nui-mgmt-delete-btn {
|
||||
background: none;
|
||||
border: 1px solid #dc3545;
|
||||
color: #dc3545;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: var(--space-1-5) var(--space-4);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-delete-btn:hover {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
background-color: var(--danger);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.nui-mgmt-select-btn {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
padding: 0.4rem 1.5rem;
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
padding: var(--space-1-5) var(--space-6);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nui-mgmt-select-btn:hover:not(:disabled) {
|
||||
background-color: #5568d3;
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.nui-mgmt-select-btn:disabled {
|
||||
background-color: #6c757d;
|
||||
background-color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import NavRail from './NavRail';
|
||||
|
||||
// Shell for every screen that carries the navigation rail. Mounted once as a
|
||||
// layout route, so the rail does not remount (and lose an open menu) when the
|
||||
// user moves between the pages inside it.
|
||||
function RailLayout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<main className="app-shell-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
<NavRail />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RailLayout;
|
||||
@@ -4,9 +4,9 @@
|
||||
top: 0;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
border-left: 1px solid #ccc;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-raised);
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
@@ -17,9 +17,9 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: #f9f9f9;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-sunken);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
@@ -27,41 +27,41 @@
|
||||
|
||||
.palette-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
font-size: var(--fs-2xl);
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 5px 10px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-1-5) var(--space-2-5);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #333;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.palette-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
|
||||
.palette-category {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: var(--space-2-5);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
padding: var(--space-2-5);
|
||||
background: var(--surface-sunken);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
@@ -69,32 +69,32 @@
|
||||
}
|
||||
|
||||
.category-header:hover {
|
||||
background: #e8e8e8;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.category-header.expanded {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
margin-right: var(--space-2);
|
||||
font-size: var(--fs-sm);
|
||||
width: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: var(--fw-medium);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.character-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
gap: var(--space-1-5);
|
||||
padding: var(--space-2-5);
|
||||
background: var(--surface-sunken);
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
@@ -104,27 +104,27 @@
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: var(--fs-2xl);
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.2s;
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.char-button:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f0ff;
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.char-button.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 4px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -135,16 +135,16 @@
|
||||
|
||||
.character-palette::-webkit-scrollbar-track,
|
||||
.palette-content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.character-palette::-webkit-scrollbar-thumb,
|
||||
.palette-content::-webkit-scrollbar-thumb {
|
||||
background: #c0c0c0;
|
||||
background: var(--border-strong);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.character-palette::-webkit-scrollbar-thumb:hover,
|
||||
.palette-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0a0a0;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -12,38 +12,38 @@
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.config-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.config-panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
color: var(--header-text);
|
||||
font-size: var(--fs-display);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
@@ -56,70 +56,70 @@
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: var(--header-chrome);
|
||||
}
|
||||
|
||||
.config-panel-content {
|
||||
padding: 20px;
|
||||
padding: var(--space-5);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.config-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.config-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: var(--fs-md);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.config-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.config-input:disabled {
|
||||
background: #f5f5f5;
|
||||
background: var(--surface-sunken);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.config-button {
|
||||
padding: 10px 16px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2-5) var(--space-4);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.config-button:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
background: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@
|
||||
}
|
||||
|
||||
.config-button.primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
background: var(--accent-gradient);
|
||||
color: var(--accent-contrast);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@@ -139,65 +139,65 @@
|
||||
}
|
||||
|
||||
.config-button.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
padding: var(--space-1-5) var(--space-3);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.config-error {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
color: #c33;
|
||||
font-size: 14px;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.config-info {
|
||||
padding: 16px;
|
||||
background: #f0f8ff;
|
||||
border: 1px solid #d0e8ff;
|
||||
padding: var(--space-4);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #0066cc;
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.manual-input-section {
|
||||
margin-top: 12px;
|
||||
margin-top: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.manual-data-input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-2-5) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: var(--fs-md);
|
||||
font-family: var(--font-mono);
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.manual-data-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.data-preview {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.data-preview pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: var(--fs-sm);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-sunken);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
width: 1440px;
|
||||
height: 2112px;
|
||||
background: white;
|
||||
color: #000;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
@@ -58,8 +59,8 @@
|
||||
|
||||
.frame-preview {
|
||||
position: absolute;
|
||||
border: 2px dashed #667eea;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 2px dashed var(--accent);
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -74,8 +75,8 @@
|
||||
|
||||
.selection-rectangle {
|
||||
position: absolute;
|
||||
border: 2px dashed #667eea;
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border: 2px dashed var(--accent);
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,93 +4,93 @@
|
||||
top: 0;
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
border-right: 1px solid #ddd;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
background: var(--surface-sunken);
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-raised);
|
||||
overflow-y: auto;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-base);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.inspector-header {
|
||||
background: #e0e0e0;
|
||||
padding: 10px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ccc;
|
||||
background: var(--surface-hover);
|
||||
padding: var(--space-2-5);
|
||||
font-weight: var(--fw-bold);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.inspector-empty {
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
padding: var(--space-5);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inspector-section {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-2-5);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
color: #555;
|
||||
font-weight: var(--fw-bold);
|
||||
margin-bottom: var(--space-2-5);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.property-row label {
|
||||
flex: 0 0 70px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.property-row input,
|
||||
.property-row select {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #ccc;
|
||||
padding: var(--space-1) var(--space-1-5);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.property-row input:disabled {
|
||||
background: #e0e0e0;
|
||||
color: #999;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.property-row select:disabled {
|
||||
background: #e0e0e0;
|
||||
color: #999;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.property-row input:focus,
|
||||
.property-row select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.expr-btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1-5);
|
||||
}
|
||||
|
||||
.expr-btn-row button {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
border: 1px solid #ccc;
|
||||
padding: var(--space-0-5) var(--space-1-5);
|
||||
font-size: var(--fs-xs);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.expr-btn-row button:hover {
|
||||
background: #e8eeff;
|
||||
border-color: #667eea;
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -176,11 +176,11 @@ function ObjectInspector({ element, onUpdate, allElements = [] }) {
|
||||
{parentBand ? (
|
||||
<>
|
||||
<div className="property-info" style={{
|
||||
fontSize: '11px',
|
||||
color: '#667eea',
|
||||
marginBottom: '8px',
|
||||
padding: '6px',
|
||||
background: 'rgba(102, 126, 234, 0.1)',
|
||||
fontSize: 'var(--fs-xs)',
|
||||
color: 'var(--accent)',
|
||||
marginBottom: 'var(--space-2)',
|
||||
padding: 'var(--space-1-5)',
|
||||
background: 'var(--accent-soft)',
|
||||
borderRadius: '3px'
|
||||
}}>
|
||||
{parentBand.parentBandId ? (
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -26,74 +26,74 @@
|
||||
}
|
||||
|
||||
.load-dialog {
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.load-dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.load-dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
font-size: var(--fs-xl);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.load-dialog-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
font-size: var(--fs-2xl);
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.load-dialog-close:hover {
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.load-dialog-actions {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.load-dialog-new-btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.load-dialog-new-btn:hover {
|
||||
background: #5a6fd6;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.load-dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 0;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.load-dialog-loading,
|
||||
.load-dialog-empty {
|
||||
padding: 40px 20px;
|
||||
padding: var(--space-10) var(--space-5);
|
||||
text-align: center;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.load-dialog-list {
|
||||
@@ -105,47 +105,47 @@
|
||||
.load-dialog-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.load-dialog-item:hover {
|
||||
background: #f8f8f8;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.load-dialog-item--current {
|
||||
background: #e8f0ff;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.load-dialog-item--current:hover {
|
||||
background: #dde8ff;
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.load-dialog-item-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.load-dialog-item-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.load-dialog-item-date {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.load-dialog-item-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
font-size: var(--fs-lg);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
gap: 16px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--header-bg);
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
box-shadow: var(--shadow-raised);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toolbar-separator {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: var(--header-chrome-border);
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
color: var(--header-text);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: var(--header-chrome);
|
||||
border: 1px solid var(--header-chrome-border);
|
||||
color: var(--header-text);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: var(--header-chrome-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toolbar-button.active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
background: color-mix(in srgb, var(--header-text) 40%, transparent);
|
||||
border-color: color-mix(in srgb, var(--header-text) 50%, transparent);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.toolbar-button:disabled {
|
||||
@@ -62,21 +62,21 @@
|
||||
}
|
||||
|
||||
.toolbar-report-name {
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--header-chrome-border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-size: var(--fs-md);
|
||||
width: 200px;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toolbar-report-name::placeholder {
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-report-name:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||
border-color: color-mix(in srgb, var(--header-text) 60%, transparent);
|
||||
box-shadow: 0 0 0 2px var(--header-chrome);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/* A4 designer canvas — screen only. Print styles arrive in Phase 4. */
|
||||
|
||||
.a4-canvas-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--desk);
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.a4-page {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.a4-page.crosshair {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Margin and page-centre overlay — purely visual, never intercepts the mouse */
|
||||
.a4-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.a4-margin-line {
|
||||
position: absolute;
|
||||
border: 0 dashed #59a2e0;
|
||||
}
|
||||
|
||||
.a4-margin-line.vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.a4-margin-line.horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.a4-center-line {
|
||||
position: absolute;
|
||||
border: 0 dashed #e08db5;
|
||||
}
|
||||
|
||||
.a4-center-line.vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.a4-center-line.horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
/* Smart alignment guides — shown only while dragging.
|
||||
A magenta accent, as Figma and PowerPoint use: it reads as UI chrome rather
|
||||
than as page content, which matters on a page that is otherwise black on
|
||||
white. Stays 1px at every zoom for the same reason — a guide marks a
|
||||
position, it does not have a width on the paper. */
|
||||
.a4-guide-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.a4-guide {
|
||||
position: absolute;
|
||||
background: #ff00c8;
|
||||
}
|
||||
|
||||
.a4-guide--v {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.a4-guide--h {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Centre alignments get a distinct cyan so "centres match" is readable at a
|
||||
glance from "edges match" — the two look identical otherwise. */
|
||||
.a4-guide--element-center,
|
||||
.a4-guide--page-center {
|
||||
background: #00b8d4;
|
||||
}
|
||||
|
||||
/* Rubber-band selection rectangle */
|
||||
.a4-selection-rectangle {
|
||||
position: absolute;
|
||||
border: 1px dashed var(--accent);
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* Preview mode — the pages layout() produced, stacked with a visible break.
|
||||
Same array the print path consumes, which is what makes "preview page count
|
||||
equals printed page count" structural rather than coincidental. */
|
||||
.a4-preview-pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.a4-preview-page-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.a4-preview-page-break {
|
||||
width: 100%;
|
||||
padding: var(--space-1-5) 0 var(--space-4);
|
||||
text-align: center;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--desk-text);
|
||||
border-top: 1px dashed color-mix(in srgb, var(--desk-text) 40%, transparent);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.a4-preview-el {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import A4TextField from './elements/A4TextField';
|
||||
import A4DBTextField from './elements/A4DBTextField';
|
||||
import A4ExprField from './elements/A4ExprField';
|
||||
import A4Rect from './elements/A4Rect';
|
||||
import A4HLine from './elements/A4HLine';
|
||||
import A4VLine from './elements/A4VLine';
|
||||
import A4Band from './elements/A4Band';
|
||||
import { mmToPx, pxToMm, roundMm, getPageSize } from './units';
|
||||
import { a4IntersectsRect, clampToPage } from './geometry';
|
||||
import { buildGuideCandidates, resolveSnap, unionRect } from './guides';
|
||||
import { layout } from './layout';
|
||||
import { styleFor } from './print/PrintDocument';
|
||||
import './A4Canvas.css';
|
||||
|
||||
/**
|
||||
* One placed element in preview mode.
|
||||
*
|
||||
* Reuses PrintDocument's `styleFor` rather than restating it, then rescales the
|
||||
* millimetre values to on-screen pixels. Preview and print therefore cannot
|
||||
* drift apart in how an element LOOKS, just as sharing layout() stops them
|
||||
* drifting in where it SITS.
|
||||
*
|
||||
* mm -> px is a pure multiply by (PX_PER_MM * zoom), so every mm length in the
|
||||
* style object is converted the same way; pt font sizes convert by 96/72.
|
||||
*/
|
||||
function PreviewElement({ el, x, y, text, zoom }) {
|
||||
const mmStyle = styleFor(el, x, y);
|
||||
const scaled = {};
|
||||
|
||||
for (const [prop, value] of Object.entries(mmStyle)) {
|
||||
if (typeof value !== 'string') {
|
||||
scaled[prop] = value;
|
||||
} else if (value.endsWith('mm')) {
|
||||
scaled[prop] = `${mmToPx(parseFloat(value), zoom)}px`;
|
||||
} else if (value.endsWith('pt')) {
|
||||
scaled[prop] = `${parseFloat(value) * (96 / 72) * zoom}px`;
|
||||
} else if (value.includes('mm')) {
|
||||
// Compound values such as "0.3mm solid #000000": scale the mm token only.
|
||||
// Checked AFTER endsWith('mm') because that is the plain-length case.
|
||||
scaled[prop] = value.replace(/([\d.]+)mm/g, (_, mm) => `${mmToPx(parseFloat(mm), zoom)}px`);
|
||||
} else {
|
||||
scaled[prop] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`a4-preview-el a4-preview-el--${el.type}`}
|
||||
style={scaled}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_WIDTH_MM = 40;
|
||||
const DEFAULT_TEXT_HEIGHT_MM = 6;
|
||||
const DEFAULT_RECT_WIDTH_MM = 50;
|
||||
const DEFAULT_RECT_HEIGHT_MM = 20;
|
||||
const DEFAULT_LINE_LENGTH_MM = 50;
|
||||
const DEFAULT_VLINE_LENGTH_MM = 30;
|
||||
const DEFAULT_THICKNESS_MM = 0.3;
|
||||
const DEFAULT_BAND_HEIGHT_MM = 20;
|
||||
|
||||
/**
|
||||
* New-element factories, one per tool.
|
||||
*
|
||||
* Each returns the element's JSON exactly as it will be stored and saved, so
|
||||
* this table is the single readable statement of every A4 element's shape.
|
||||
* `footprint` is what the page clamp uses: for a line that is its thickness on
|
||||
* the axis it does not run along, which is why it is stated separately rather
|
||||
* than read off width/height.
|
||||
*/
|
||||
const ELEMENT_FACTORIES = {
|
||||
addText: (pageSetup) => ({
|
||||
type: 'a4-text',
|
||||
width: DEFAULT_TEXT_WIDTH_MM,
|
||||
height: DEFAULT_TEXT_HEIGHT_MM,
|
||||
content: 'Text',
|
||||
...fontDefaults(pageSetup)
|
||||
}),
|
||||
addDBText: (pageSetup) => ({
|
||||
type: 'a4-dbtext',
|
||||
width: DEFAULT_TEXT_WIDTH_MM,
|
||||
height: DEFAULT_TEXT_HEIGHT_MM,
|
||||
objectKey: '',
|
||||
fieldPath: '',
|
||||
dataType: 'general',
|
||||
...fontDefaults(pageSetup)
|
||||
}),
|
||||
addExpr: (pageSetup) => ({
|
||||
type: 'a4-expr',
|
||||
width: DEFAULT_TEXT_WIDTH_MM,
|
||||
height: DEFAULT_TEXT_HEIGHT_MM,
|
||||
expression: '',
|
||||
dataType: 'general',
|
||||
...fontDefaults(pageSetup)
|
||||
}),
|
||||
addRect: () => ({
|
||||
type: 'a4-rect',
|
||||
width: DEFAULT_RECT_WIDTH_MM,
|
||||
height: DEFAULT_RECT_HEIGHT_MM,
|
||||
borderWidth: DEFAULT_THICKNESS_MM,
|
||||
borderColor: '#000000',
|
||||
fillColor: null
|
||||
}),
|
||||
addHLine: () => ({
|
||||
type: 'a4-hline',
|
||||
width: DEFAULT_LINE_LENGTH_MM,
|
||||
thickness: DEFAULT_THICKNESS_MM,
|
||||
color: '#000000'
|
||||
}),
|
||||
addVLine: () => ({
|
||||
type: 'a4-vline',
|
||||
height: DEFAULT_VLINE_LENGTH_MM,
|
||||
thickness: DEFAULT_THICKNESS_MM,
|
||||
color: '#000000'
|
||||
}),
|
||||
addBand: (pageSetup) => ({
|
||||
type: 'a4-band',
|
||||
// x and width are derived from the page's content area, never user-set —
|
||||
// see A4Band.jsx for why offering a width control would be a trap.
|
||||
x: pageSetup.margins?.left || 0,
|
||||
width: contentWidthOf(pageSetup),
|
||||
height: DEFAULT_BAND_HEIGHT_MM,
|
||||
bandType: 'detail',
|
||||
dataSource: '',
|
||||
caption: '',
|
||||
children: [],
|
||||
parentBandId: null
|
||||
})
|
||||
};
|
||||
|
||||
/** A band spans the page's content width: page width less both side margins. */
|
||||
const contentWidthOf = (pageSetup) => {
|
||||
const size = getPageSize(pageSetup);
|
||||
const m = pageSetup?.margins || {};
|
||||
return roundMm(size.w - (m.left || 0) - (m.right || 0));
|
||||
};
|
||||
|
||||
const fontDefaults = (pageSetup) => ({
|
||||
fontFamily: pageSetup.defaultFont || 'Arial',
|
||||
fontSize: pageSetup.defaultFontSize || 10,
|
||||
bold: false,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
/** Box a type occupies for clamping: lines substitute thickness for the axis
|
||||
* they do not run along, matching getA4Bounds. */
|
||||
const footprintOf = (draft) => ({
|
||||
w: (draft.type === 'a4-vline' ? draft.thickness : draft.width) || 0,
|
||||
h: (draft.type === 'a4-hline' ? draft.thickness : draft.height) || 0
|
||||
});
|
||||
|
||||
/**
|
||||
* A4 page canvas — everything positioned in millimetre floats.
|
||||
*
|
||||
* Structural reference: the matrix `EditorCanvas.jsx` (click/drag/rubber-band
|
||||
* patterns). Its grid snapping is deliberately NOT carried over: mouse pixels
|
||||
* convert straight to mm via pxToMm(px, zoom) and are rounded only to 0.1mm.
|
||||
* Snapping to margins and neighbouring elements is Phase 3.
|
||||
*/
|
||||
function A4Canvas({
|
||||
elements,
|
||||
selectedElementIds,
|
||||
onElementSelect,
|
||||
onSelectMultiple,
|
||||
onDeselectAll,
|
||||
onElementUpdate,
|
||||
onAddElement,
|
||||
onElementDelete,
|
||||
toolMode,
|
||||
pageSetup,
|
||||
zoom,
|
||||
previewMode = false,
|
||||
reportData = null
|
||||
}) {
|
||||
const pageRef = useRef(null);
|
||||
const [selectionRect, setSelectionRect] = useState(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [didDragSelect, setDidDragSelect] = useState(false);
|
||||
|
||||
// Smart-guide state. The candidate set is a ref, not state: it is built once
|
||||
// per drag and read on every mousemove, and rebuilding it per move is the one
|
||||
// thing that would make dragging laggy (100 elements is ~600 candidates).
|
||||
const guideCandidatesRef = useRef({ V: [], H: [] });
|
||||
// Alt suppresses snapping. useElementDrag forwards only pixel deltas, and it
|
||||
// is shared with the matrix editor, so the modifier is tracked here from the
|
||||
// window rather than by widening that hook's contract.
|
||||
const altPressedRef = useRef(false);
|
||||
const [activeGuides, setActiveGuides] = useState([]);
|
||||
|
||||
const pageSize = getPageSize(pageSetup);
|
||||
const pageWidthPx = mmToPx(pageSize.w, zoom);
|
||||
const pageHeightPx = mmToPx(pageSize.h, zoom);
|
||||
|
||||
/** Mouse event → page-relative millimetres. */
|
||||
const eventToMm = (e) => {
|
||||
const rect = pageRef.current.getBoundingClientRect();
|
||||
return {
|
||||
xMm: pxToMm(e.clientX - rect.left, zoom),
|
||||
yMm: pxToMm(e.clientY - rect.top, zoom)
|
||||
};
|
||||
};
|
||||
|
||||
const handlePageMouseDown = (e) => {
|
||||
if (toolMode !== 'select') return;
|
||||
const { xMm, yMm } = eventToMm(e);
|
||||
setIsSelecting(true);
|
||||
setDidDragSelect(false);
|
||||
setSelectionRect({ startX: xMm, startY: yMm, endX: xMm, endY: yMm });
|
||||
};
|
||||
|
||||
const handlePageMouseMove = (e) => {
|
||||
if (!isSelecting || toolMode !== 'select') return;
|
||||
const { xMm, yMm } = eventToMm(e);
|
||||
setSelectionRect(prev => {
|
||||
if (!prev) return prev;
|
||||
if (Math.abs(xMm - prev.startX) > 0.5 || Math.abs(yMm - prev.startY) > 0.5) {
|
||||
setDidDragSelect(true);
|
||||
}
|
||||
return { ...prev, endX: xMm, endY: yMm };
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageClick = (e) => {
|
||||
const { xMm, yMm } = eventToMm(e);
|
||||
|
||||
const factory = ELEMENT_FACTORIES[toolMode];
|
||||
|
||||
if (factory) {
|
||||
const draft = factory(pageSetup);
|
||||
const { w, h } = footprintOf(draft);
|
||||
const { x, y } = clampToPage(xMm, yMm, w, h, pageSize.w, pageSize.h);
|
||||
|
||||
onAddElement({
|
||||
// The id prefix mirrors the type so a stray element is identifiable in
|
||||
// saved JSON without cross-referencing its `type`.
|
||||
id: `${draft.type}-${Date.now()}`,
|
||||
x: roundMm(x),
|
||||
y: roundMm(y),
|
||||
// AFTER the click position, so a factory that fixes its own x wins.
|
||||
// A band's x comes from the page margins, so only the click's Y means
|
||||
// anything — taking its X would drop the band wherever the pointer
|
||||
// happened to be, and the drag lock would then preserve that mistake.
|
||||
...draft
|
||||
});
|
||||
} else if (toolMode === 'select') {
|
||||
if (!didDragSelect) {
|
||||
onDeselectAll();
|
||||
}
|
||||
setDidDragSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDragStart = useCallback((elementId) => {
|
||||
// Snapshot every selected element so a multi-drag stays relative to where it
|
||||
// began rather than accumulating rounding error per mousemove.
|
||||
const ids = selectedElementIds.includes(elementId) && selectedElementIds.length > 1
|
||||
? selectedElementIds
|
||||
: [elementId];
|
||||
|
||||
const initial = ids.map(id => {
|
||||
const el = elements.find(e => e.id === id);
|
||||
const { w, h } = footprintOf(el);
|
||||
// A band's x is derived from the page margins, never dragged — see the
|
||||
// constraint note in A4Band.jsx. Recorded per element so a band caught in
|
||||
// a mixed multi-selection still holds its x while the rest move freely.
|
||||
return { id, x: el.x, y: el.y, width: w, height: h, lockX: el.type === 'a4-band' };
|
||||
});
|
||||
|
||||
// Build the guide candidates ONCE, here. Everything being dragged is
|
||||
// excluded — an element cannot meaningfully align to itself, and including
|
||||
// it would snap it to its own starting position and never let go.
|
||||
guideCandidatesRef.current = buildGuideCandidates(elements, ids, pageSetup);
|
||||
|
||||
return { elementId, initial };
|
||||
}, [elements, selectedElementIds, pageSetup]);
|
||||
|
||||
const handleElementDrag = useCallback((dragData, deltaX, deltaY) => {
|
||||
const deltaMmX = pxToMm(deltaX, zoom);
|
||||
const deltaMmY = pxToMm(deltaY, zoom);
|
||||
|
||||
// Snap the UNION of the selection, then apply one delta to every member.
|
||||
// Snapping each element separately would let them find different candidates
|
||||
// and tear the group apart mid-drag.
|
||||
const start = unionRect(dragData.initial);
|
||||
const draft = { ...start, x: start.x + deltaMmX, y: start.y + deltaMmY };
|
||||
|
||||
let snapped = { x: draft.x, y: draft.y, guides: [] };
|
||||
if (!altPressedRef.current) {
|
||||
snapped = resolveSnap(draft, guideCandidatesRef.current, zoom);
|
||||
}
|
||||
|
||||
// The correction the snap applied to the union, re-expressed as the total
|
||||
// delta each member takes from ITS OWN start — so relative offsets hold.
|
||||
const snapDeltaX = deltaMmX + (snapped.x - draft.x);
|
||||
const snapDeltaY = deltaMmY + (snapped.y - draft.y);
|
||||
|
||||
dragData.initial.forEach(({ id, x, y, width, height, lockX }) => {
|
||||
const clamped = clampToPage(
|
||||
x + (lockX ? 0 : snapDeltaX), y + snapDeltaY, width, height, pageSize.w, pageSize.h
|
||||
);
|
||||
// A locked element reports only y, so no x write can creep in via rounding.
|
||||
onElementUpdate(id, lockX
|
||||
? { y: roundMm(clamped.y) }
|
||||
: { x: roundMm(clamped.x), y: roundMm(clamped.y) });
|
||||
});
|
||||
|
||||
setActiveGuides(snapped.guides);
|
||||
// pageSize is rebuilt every render, so depend on its two scalars instead.
|
||||
}, [zoom, onElementUpdate, pageSize.w, pageSize.h]);
|
||||
|
||||
// Guides belong to the drag, so they are cleared on any mouseup — including
|
||||
// one released outside the page, which useElementDrag also listens for.
|
||||
useEffect(() => {
|
||||
const clear = () => setActiveGuides(prev => (prev.length ? [] : prev));
|
||||
document.addEventListener('mouseup', clear);
|
||||
return () => document.removeEventListener('mouseup', clear);
|
||||
}, []);
|
||||
|
||||
// Alt is read from the window because useElementDrag (shared with the matrix
|
||||
// editor) forwards no modifier state. `blur` resets it: alt-tabbing away
|
||||
// otherwise leaves snapping permanently suppressed, since the keyup lands in
|
||||
// another window.
|
||||
useEffect(() => {
|
||||
const sync = (e) => { altPressedRef.current = e.altKey; };
|
||||
const reset = () => { altPressedRef.current = false; };
|
||||
window.addEventListener('keydown', sync);
|
||||
window.addEventListener('keyup', sync);
|
||||
window.addEventListener('blur', reset);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', sync);
|
||||
window.removeEventListener('keyup', sync);
|
||||
window.removeEventListener('blur', reset);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Rubber-band selection completes on mouseup anywhere, so a release outside
|
||||
// the page still resolves the selection instead of leaving it stuck.
|
||||
useEffect(() => {
|
||||
if (!isSelecting) return;
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
if (selectionRect) {
|
||||
const rect = {
|
||||
minX: Math.min(selectionRect.startX, selectionRect.endX),
|
||||
maxX: Math.max(selectionRect.startX, selectionRect.endX),
|
||||
minY: Math.min(selectionRect.startY, selectionRect.endY),
|
||||
maxY: Math.max(selectionRect.startY, selectionRect.endY)
|
||||
};
|
||||
// A click (no drag) must not select everything it happens to touch
|
||||
if (rect.maxX - rect.minX > 0.5 || rect.maxY - rect.minY > 0.5) {
|
||||
onSelectMultiple(
|
||||
elements.filter(el => a4IntersectsRect(el, rect)).map(el => el.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsSelecting(false);
|
||||
setSelectionRect(null);
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
}, [isSelecting, selectionRect, elements, onSelectMultiple]);
|
||||
|
||||
const margins = pageSetup.margins || {};
|
||||
|
||||
const renderOverlay = () => (
|
||||
<div className="a4-overlay">
|
||||
<div className="a4-margin-line vertical" style={{ left: `${mmToPx(margins.left || 0, zoom)}px` }} />
|
||||
<div className="a4-margin-line vertical" style={{ left: `${mmToPx(pageSize.w - (margins.right || 0), zoom)}px` }} />
|
||||
<div className="a4-margin-line horizontal" style={{ top: `${mmToPx(margins.top || 0, zoom)}px` }} />
|
||||
<div className="a4-margin-line horizontal" style={{ top: `${mmToPx(pageSize.h - (margins.bottom || 0), zoom)}px` }} />
|
||||
<div className="a4-center-line vertical" style={{ left: `${mmToPx(pageSize.w / 2, zoom)}px` }} />
|
||||
<div className="a4-center-line horizontal" style={{ top: `${mmToPx(pageSize.h / 2, zoom)}px` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Alignment guides — 1px lines in their own pointer-events:none layer.
|
||||
*
|
||||
* Driven by local A4Canvas state so a drag re-renders this overlay rather
|
||||
* than the element tree. Width/height stay 1px at every zoom: a guide is a
|
||||
* piece of UI chrome, not page content, so it must not scale with the page.
|
||||
*/
|
||||
const renderGuides = () => {
|
||||
if (!activeGuides.length) return null;
|
||||
return (
|
||||
<div className="a4-guide-overlay">
|
||||
{activeGuides.map(({ axis, pos, kind }) => (
|
||||
<div
|
||||
key={`${axis}-${pos}`}
|
||||
className={`a4-guide a4-guide--${axis} a4-guide--${kind}`}
|
||||
style={axis === 'v'
|
||||
? { left: `${mmToPx(pos, zoom)}px` }
|
||||
: { top: `${mmToPx(pos, zoom)}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectionRect = () => {
|
||||
if (!selectionRect || !isSelecting) return null;
|
||||
const minX = Math.min(selectionRect.startX, selectionRect.endX);
|
||||
const minY = Math.min(selectionRect.startY, selectionRect.endY);
|
||||
return (
|
||||
<div
|
||||
className="a4-selection-rectangle"
|
||||
style={{
|
||||
left: `${mmToPx(minX, zoom)}px`,
|
||||
top: `${mmToPx(minY, zoom)}px`,
|
||||
width: `${mmToPx(Math.abs(selectionRect.endX - selectionRect.startX), zoom)}px`,
|
||||
height: `${mmToPx(Math.abs(selectionRect.endY - selectionRect.startY), zoom)}px`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderElement = (element) => {
|
||||
const commonProps = {
|
||||
element,
|
||||
isSelected: selectedElementIds.includes(element.id),
|
||||
isSingleSelection: selectedElementIds.length === 1 && selectedElementIds[0] === element.id,
|
||||
onSelect: (e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey),
|
||||
onUpdate: (updates) => onElementUpdate(element.id, updates),
|
||||
onDelete: () => onElementDelete(element.id),
|
||||
onDragStart: handleElementDragStart,
|
||||
onDrag: handleElementDrag,
|
||||
zoom,
|
||||
toolMode
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case 'a4-text':
|
||||
return <A4TextField key={element.id} {...commonProps} />;
|
||||
case 'a4-dbtext':
|
||||
return <A4DBTextField key={element.id} {...commonProps} />;
|
||||
case 'a4-expr':
|
||||
return <A4ExprField key={element.id} {...commonProps} />;
|
||||
case 'a4-rect':
|
||||
return <A4Rect key={element.id} {...commonProps} />;
|
||||
case 'a4-hline':
|
||||
return <A4HLine key={element.id} {...commonProps} />;
|
||||
case 'a4-vline':
|
||||
return <A4VLine key={element.id} {...commonProps} />;
|
||||
case 'a4-band':
|
||||
// Children render INSIDE the band, so their stored y stays relative to
|
||||
// it and CSS does the offset — same containment model as the matrix
|
||||
// editor, and what layout() assumes when it emits `y + child.y`.
|
||||
return (
|
||||
<A4Band key={element.id} {...commonProps}>
|
||||
{(element.children || [])
|
||||
.map(childId => elements.find(el => el.id === childId))
|
||||
.filter(Boolean)
|
||||
.map(renderElement)}
|
||||
</A4Band>
|
||||
);
|
||||
default:
|
||||
// An unknown type renders nothing rather than throwing, so one bad
|
||||
// element cannot blank the whole canvas.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Ids owned by a band — rendered by their parent, not at the top level. */
|
||||
const childIds = new Set();
|
||||
elements.forEach(el => {
|
||||
if (el.type === 'a4-band') (el.children || []).forEach(id => childIds.add(id));
|
||||
});
|
||||
|
||||
// PREVIEW MODE renders layout()'s output — the exact pages array the print
|
||||
// path consumes. That shared source is what makes "preview page count equals
|
||||
// printed page count" a structural guarantee rather than a coincidence, so
|
||||
// preview must never grow its own positioning logic.
|
||||
if (previewMode) {
|
||||
const { pages } = layout(elements, reportData, pageSetup);
|
||||
|
||||
return (
|
||||
<div className="a4-canvas-container">
|
||||
<div className="a4-preview-pages">
|
||||
{pages.map((pageElements, pageIndex) => (
|
||||
<div key={pageIndex} className="a4-preview-page-wrapper">
|
||||
<div
|
||||
className="a4-page"
|
||||
style={{ width: `${pageWidthPx}px`, height: `${pageHeightPx}px` }}
|
||||
>
|
||||
{pageElements.map(({ el, x, y, text, instanceKey }) => (
|
||||
<PreviewElement
|
||||
key={instanceKey ? `${instanceKey}-${el.id}` : el.id}
|
||||
el={el}
|
||||
x={x}
|
||||
y={y}
|
||||
text={text}
|
||||
zoom={zoom}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="a4-preview-page-break">
|
||||
Page {pageIndex + 1} of {pages.length}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="a4-canvas-container">
|
||||
<div
|
||||
ref={pageRef}
|
||||
className={`a4-page ${ELEMENT_FACTORIES[toolMode] ? 'crosshair' : ''}`}
|
||||
style={{ width: `${pageWidthPx}px`, height: `${pageHeightPx}px` }}
|
||||
onClick={handlePageClick}
|
||||
onMouseDown={handlePageMouseDown}
|
||||
onMouseMove={handlePageMouseMove}
|
||||
>
|
||||
{renderOverlay()}
|
||||
{renderSelectionRect()}
|
||||
{elements.filter(el => !childIds.has(el.id)).map(renderElement)}
|
||||
{/* Last, so guides paint above the elements they align */}
|
||||
{renderGuides()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Canvas;
|
||||
@@ -0,0 +1,61 @@
|
||||
/* A4 inspector — layered on top of the shared ObjectInspector.css shell. */
|
||||
|
||||
.a4-inspector .mm-suffix {
|
||||
flex: 0 0 auto;
|
||||
margin-left: var(--space-1-5);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-buttons {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-btn {
|
||||
flex: 1;
|
||||
padding: var(--space-1) 0;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.a4-inspector .a4-style-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.a4-inspector input[type="color"] {
|
||||
padding: 1px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.a4-inspector .a4-expression-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: var(--space-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.a4-inspector .inspector-hint {
|
||||
padding: var(--space-0-5) 0 var(--space-1-5);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.a4-inspector input[type="checkbox"] {
|
||||
flex: 0 0 auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { roundMm } from './units';
|
||||
import { FONT_FAMILIES } from './fonts';
|
||||
import { useReportData } from '../DataContext';
|
||||
import { getAvailableObjects, getAvailableFields } from '../utils/dataResolver';
|
||||
import { getAvailableArrays } from './a4BandRenderer';
|
||||
import '../ObjectInspector.css';
|
||||
import './A4Inspector.css';
|
||||
|
||||
/**
|
||||
* NOTE ON DUPLICATION — deliberate, see the plan's "matrix editor is not
|
||||
* modified" section. The data-binding and expression sections below are adapted
|
||||
* from `ObjectInspector.jsx` rather than imported from it. That file drives the
|
||||
* one working report feature and parses geometry with parseInt; A4 needs
|
||||
* parseFloat millimetres and the `align`/`a4-*` property names. These sections
|
||||
* WILL drift from the matrix ones over time. That is the accepted price of not
|
||||
* touching working code.
|
||||
*/
|
||||
|
||||
const DATA_TYPES = [
|
||||
{ value: 'general', label: 'General' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'time', label: 'Time' },
|
||||
{ value: 'datetime', label: 'Date & Time' }
|
||||
];
|
||||
|
||||
const TYPE_LABELS = {
|
||||
'a4-text': 'Text',
|
||||
'a4-dbtext': 'Data Field',
|
||||
'a4-expr': 'Expression',
|
||||
'a4-rect': 'Rectangle',
|
||||
'a4-hline': 'Horizontal Line',
|
||||
'a4-vline': 'Vertical Line'
|
||||
};
|
||||
|
||||
/**
|
||||
* Millimetre number input with local draft-string state.
|
||||
*
|
||||
* This is why it exists: a naive `parseFloat` + `isNaN` guard makes typing "12."
|
||||
* impossible. The intermediate value parses to 12, re-renders the input as "12",
|
||||
* and the decimal point the user just typed disappears. So the input owns the
|
||||
* raw string while focused, commits whenever the draft parses, and re-syncs from
|
||||
* the prop only when the value changed elsewhere (drag, resize, load).
|
||||
*/
|
||||
export function MmInput({ label, value, onCommit, min, step = 0.1 }) {
|
||||
const [draft, setDraft] = useState(String(value ?? ''));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setDraft(String(value ?? ''));
|
||||
}
|
||||
}, [value, isFocused]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
|
||||
// Commit only when the draft is a complete number. "12." and "" stay in the
|
||||
// draft untouched, so the caret and the typed character survive.
|
||||
const parsed = parseFloat(next);
|
||||
if (next.trim() !== '' && !Number.isNaN(parsed) && /^-?\d*\.?\d*$/.test(next.trim())) {
|
||||
const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
|
||||
onCommit(roundMm(clamped));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
const parsed = parseFloat(draft);
|
||||
|
||||
if (draft.trim() === '' || Number.isNaN(parsed)) {
|
||||
// Abandoned edit — snap the field back to the element's real value
|
||||
setDraft(String(value ?? ''));
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = min !== undefined ? Math.max(min, parsed) : parsed;
|
||||
const rounded = roundMm(clamped);
|
||||
onCommit(rounded);
|
||||
setDraft(String(rounded));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>{label}</label>
|
||||
{/* type="text" + inputMode="decimal", NOT type="number": a number input
|
||||
sanitises partial values, so the browser throws away the "12." the user
|
||||
is midway through typing and the decimal point appears not to type. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
data-step={step}
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<span className="mm-suffix">mm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Font family / size / style / alignment / colour — shared by all text types. */
|
||||
function FontSection({ element, onUpdate }) {
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Font</div>
|
||||
<div className="property-row">
|
||||
<label>Family</label>
|
||||
<select
|
||||
value={element.fontFamily || 'Arial'}
|
||||
onChange={(e) => onUpdate({ fontFamily: e.target.value })}
|
||||
>
|
||||
{FONT_FAMILIES.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Size (pt)</label>
|
||||
{/* pt, not mm: a number input is fine here because the step is 0.5 and
|
||||
users type whole or half points, never a long decimal tail. */}
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="1"
|
||||
value={element.fontSize ?? 10}
|
||||
onChange={(e) => {
|
||||
const parsed = parseFloat(e.target.value);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) onUpdate({ fontSize: parsed });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Style</label>
|
||||
<div className="a4-style-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className={`a4-style-btn ${element.bold ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ bold: !element.bold })}
|
||||
title="Bold"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`a4-style-btn ${element.italic ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ italic: !element.italic })}
|
||||
title="Italic"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Align</label>
|
||||
<select
|
||||
value={element.align || 'left'}
|
||||
onChange={(e) => onUpdate({ align: e.target.value })}
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<label>Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.color || '#000000'}
|
||||
onChange={(e) => onUpdate({ color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTypeRow({ element, onUpdate }) {
|
||||
return (
|
||||
<div className="property-row">
|
||||
<label>Data Type</label>
|
||||
<select
|
||||
value={element.dataType || 'general'}
|
||||
onChange={(e) => onUpdate({ dataType: e.target.value })}
|
||||
>
|
||||
{DATA_TYPES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Object / field pickers driven by the live reportData.
|
||||
*
|
||||
* Both are rendered as selects with the stored value present as an option even
|
||||
* when it is absent from the current data. Otherwise loading a saved report
|
||||
* before its preview data arrives would show an empty select and silently
|
||||
* clear the binding on the next change — losing work that was on screen.
|
||||
*/
|
||||
function BindingSection({ element, onUpdate, reportData }) {
|
||||
const objects = getAvailableObjects(reportData);
|
||||
const fields = element.objectKey && reportData?.[element.objectKey]
|
||||
? getAvailableFields(reportData[element.objectKey])
|
||||
: [];
|
||||
|
||||
const objectOptions = element.objectKey && !objects.includes(element.objectKey)
|
||||
? [element.objectKey, ...objects]
|
||||
: objects;
|
||||
const fieldOptions = element.fieldPath && !fields.includes(element.fieldPath)
|
||||
? [element.fieldPath, ...fields]
|
||||
: fields;
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Data Binding</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Object</label>
|
||||
<select
|
||||
value={element.objectKey || ''}
|
||||
onChange={(e) => onUpdate({ objectKey: e.target.value, fieldPath: '' })}
|
||||
>
|
||||
<option value="">-- object --</option>
|
||||
{objectOptions.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Field</label>
|
||||
<select
|
||||
value={element.fieldPath || ''}
|
||||
onChange={(e) => onUpdate({ fieldPath: e.target.value })}
|
||||
disabled={!element.objectKey}
|
||||
>
|
||||
<option value="">-- field --</option>
|
||||
{fieldOptions.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{objects.length === 0 && (
|
||||
<div className="inspector-hint">
|
||||
No preview data loaded — type a binding after fetching data, or load a
|
||||
report with an API endpoint configured.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTypeRow element={element} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expression source.
|
||||
*
|
||||
* NOT `ExpressionEditorPanel.jsx`. That component is coupled to the matrix
|
||||
* editor in two ways that would misbehave here: it looks up the parent band
|
||||
* with `el.type === 'band'` (A4 bands are `a4-band`), and its alignment select
|
||||
* writes `element.alignment`, whereas every A4 element and renderer reads
|
||||
* `element.align`. Using it would silently produce a second, dead alignment
|
||||
* property on A4 elements. A plain textarea plus a field-insert picker covers
|
||||
* the same ground without that coupling.
|
||||
*/
|
||||
function ExpressionSection({ element, onUpdate, reportData }) {
|
||||
const [selectedField, setSelectedField] = useState('');
|
||||
|
||||
const availableFields = [];
|
||||
getAvailableObjects(reportData).forEach(objectKey => {
|
||||
getAvailableFields(reportData[objectKey])
|
||||
.forEach(f => availableFields.push(`${objectKey}.${f}`));
|
||||
});
|
||||
|
||||
const insertField = () => {
|
||||
if (!selectedField) return;
|
||||
onUpdate({ expression: `${element.expression || ''}[${selectedField}]` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Expression</div>
|
||||
|
||||
<textarea
|
||||
className="a4-expression-input"
|
||||
value={element.expression || ''}
|
||||
onChange={(e) => onUpdate({ expression: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="[vehicle.gross] - [vehicle.tare]"
|
||||
/>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Insert</label>
|
||||
<select value={selectedField} onChange={(e) => setSelectedField(e.target.value)}>
|
||||
<option value="">-- field --</option>
|
||||
{availableFields.map(f => <option key={f} value={f}>{f}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="property-row">
|
||||
<button type="button" onClick={insertField} disabled={!selectedField}>
|
||||
Insert Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataTypeRow element={element} onUpdate={onUpdate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RectSection({ element, onUpdate }) {
|
||||
const hasFill = element.fillColor !== null && element.fillColor !== undefined;
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Border & Fill</div>
|
||||
|
||||
<MmInput
|
||||
label="Border"
|
||||
value={element.borderWidth ?? 0.3}
|
||||
min={0}
|
||||
onCommit={(borderWidth) => onUpdate({ borderWidth })}
|
||||
/>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Border Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.borderColor || '#000000'}
|
||||
onChange={(e) => onUpdate({ borderColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Fill</label>
|
||||
{/* `fillColor: null` is "no fill" — distinct from white, which would
|
||||
hide anything the rect is framing. A colour input cannot express
|
||||
null, so the checkbox owns that state. */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasFill}
|
||||
onChange={(e) => onUpdate({ fillColor: e.target.checked ? '#ffffff' : null })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasFill && (
|
||||
<div className="property-row">
|
||||
<label>Fill Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.fillColor}
|
||||
onChange={(e) => onUpdate({ fillColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineSection({ element, onUpdate }) {
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Line</div>
|
||||
<MmInput
|
||||
label="Thickness"
|
||||
value={element.thickness ?? 0.3}
|
||||
min={0.1}
|
||||
onCommit={(thickness) => onUpdate({ thickness })}
|
||||
/>
|
||||
<div className="property-row">
|
||||
<label>Colour</label>
|
||||
<input
|
||||
type="color"
|
||||
value={element.color || '#000000'}
|
||||
onChange={(e) => onUpdate({ color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BAND_TYPES = [
|
||||
{ value: 'header', label: 'Header — repeats at the top of every page' },
|
||||
{ value: 'detail', label: 'Detail — one instance per data row' },
|
||||
{ value: 'subdetail', label: 'Subdetail — nested inside a detail band' },
|
||||
{ value: 'summary', label: 'Summary — once, after the detail rows' },
|
||||
{ value: 'footer', label: 'Footer — repeats at the bottom of every page' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Band properties.
|
||||
*
|
||||
* `dataSource` lists the arrays actually present in the current report data, so
|
||||
* a user picks a real one rather than typing a path that silently resolves to
|
||||
* nothing. X and Width are absent by design — a band spans the page's content
|
||||
* width, derived from the margins (see A4Band.jsx).
|
||||
*/
|
||||
function BandSection({ element, onUpdate, reportData, allElements }) {
|
||||
const arrays = getAvailableArrays(reportData);
|
||||
const otherBands = (allElements || []).filter(
|
||||
el => el.type === 'a4-band' && el.id !== element.id
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Band</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Type</label>
|
||||
<select
|
||||
value={element.bandType || 'detail'}
|
||||
onChange={(e) => onUpdate({ bandType: e.target.value })}
|
||||
>
|
||||
{BAND_TYPES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="property-row">
|
||||
<label>Data source</label>
|
||||
<select
|
||||
value={element.dataSource || ''}
|
||||
onChange={(e) => onUpdate({ dataSource: e.target.value })}
|
||||
>
|
||||
<option value="">(none — renders once)</option>
|
||||
{arrays.map(({ key, path }) => (
|
||||
<option key={path} value={path}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{element.bandType === 'subdetail' && (
|
||||
<div className="property-row">
|
||||
<label>Inside band</label>
|
||||
<select
|
||||
value={element.parentBandId || ''}
|
||||
onChange={(e) => onUpdate({ parentBandId: e.target.value || null })}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{otherBands.map(b => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.caption || b.bandType || b.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="property-row">
|
||||
<label>Caption</label>
|
||||
<input
|
||||
type="text"
|
||||
value={element.caption || ''}
|
||||
onChange={(e) => onUpdate({ caption: e.target.value })}
|
||||
placeholder="Shown in the editor only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="inspector-hint">
|
||||
Elements dropped inside a band repeat with it. Anything outside a band
|
||||
prints on page 1 only — put what should repeat on every page into a
|
||||
header or footer band.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function A4Inspector({ element, onUpdate, allElements }) {
|
||||
const { reportData } = useReportData();
|
||||
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="object-inspector a4-inspector">
|
||||
<div className="inspector-header">Properties</div>
|
||||
<div className="inspector-empty">No element selected</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { type } = element;
|
||||
const isTextual = type === 'a4-text' || type === 'a4-dbtext' || type === 'a4-expr';
|
||||
|
||||
return (
|
||||
<div className="object-inspector a4-inspector">
|
||||
<div className="inspector-header">
|
||||
{TYPE_LABELS[type] || type} · {element.id}
|
||||
</div>
|
||||
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Geometry</div>
|
||||
{type !== 'a4-band' && (
|
||||
<MmInput label="X" value={element.x} min={0} onCommit={(x) => onUpdate({ x })} />
|
||||
)}
|
||||
<MmInput label="Y" value={element.y} min={0} onCommit={(y) => onUpdate({ y })} />
|
||||
{/* A line has only the dimension it runs along; offering the other
|
||||
would write a property its renderer never reads. A band's width and
|
||||
x come from the page margins, so they are not user-set either. */}
|
||||
{type !== 'a4-vline' && type !== 'a4-band' && (
|
||||
<MmInput label="Width" value={element.width} min={1}
|
||||
onCommit={(width) => onUpdate({ width })} />
|
||||
)}
|
||||
{type !== 'a4-hline' && (
|
||||
<MmInput label="Height" value={element.height} min={1}
|
||||
onCommit={(height) => onUpdate({ height })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'a4-text' && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Content</div>
|
||||
<div className="property-row">
|
||||
<label>Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={element.content || ''}
|
||||
onChange={(e) => onUpdate({ content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'a4-dbtext' && (
|
||||
<BindingSection element={element} onUpdate={onUpdate} reportData={reportData} />
|
||||
)}
|
||||
|
||||
{type === 'a4-expr' && (
|
||||
<ExpressionSection element={element} onUpdate={onUpdate} reportData={reportData} />
|
||||
)}
|
||||
|
||||
{type === 'a4-rect' && <RectSection element={element} onUpdate={onUpdate} />}
|
||||
|
||||
{(type === 'a4-hline' || type === 'a4-vline') && (
|
||||
<LineSection element={element} onUpdate={onUpdate} />
|
||||
)}
|
||||
|
||||
{type === 'a4-band' && (
|
||||
<BandSection
|
||||
element={element}
|
||||
onUpdate={onUpdate}
|
||||
reportData={reportData}
|
||||
allElements={allElements}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTextual && <FontSection element={element} onUpdate={onUpdate} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4Inspector;
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Kind chooser — sits above the load dialog (z-index 1000 in ReportEditor.css). */
|
||||
|
||||
.a4-kind-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.a4-kind-dialog {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: 460px;
|
||||
max-width: 92vw;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.a4-kind-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.a4-kind-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a4-kind-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--fs-2xl);
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.a4-kind-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a4-kind-options {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.a4-kind-option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1-5);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-sunken);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.a4-kind-option:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.a4-kind-option-icon {
|
||||
font-size: var(--fs-2xl);
|
||||
}
|
||||
|
||||
.a4-kind-option-title {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a4-kind-option-desc {
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Cross-kind group in the load dialog — dimmed, because these are reports the
|
||||
current editor cannot open; clicking one navigates to the other editor. */
|
||||
.load-dialog-group-title {
|
||||
padding: var(--space-2-5) var(--space-1) var(--space-1);
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.load-dialog-item--other {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.load-dialog-item--other:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.load-dialog-kind-badge {
|
||||
margin-left: var(--space-2);
|
||||
padding: 1px var(--space-1-5);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import './A4KindChooser.css';
|
||||
|
||||
/**
|
||||
* "What kind of report?" — shown by the load dialog's + New Report button.
|
||||
*
|
||||
* Two kinds exist and they are not interchangeable: a matrix report is a
|
||||
* character grid for a dot-matrix printer, an A4 report is millimetres for a
|
||||
* laser printer or PDF. Picking the wrong one is only discovered after the
|
||||
* layout has been drawn, so the choice is made up front rather than being a
|
||||
* setting to change later (there is no converter between the two).
|
||||
*
|
||||
* @param {(kind: 'matrix'|'a4') => void} onChoose
|
||||
*/
|
||||
function A4KindChooser({ onChoose, onCancel }) {
|
||||
return (
|
||||
<div className="a4-kind-overlay" onClick={onCancel}>
|
||||
<div className="a4-kind-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="a4-kind-header">
|
||||
<h2>New report</h2>
|
||||
<button className="a4-kind-close" onClick={onCancel} title="Cancel">×</button>
|
||||
</div>
|
||||
<div className="a4-kind-options">
|
||||
<button className="a4-kind-option" onClick={() => onChoose('matrix')}>
|
||||
<span className="a4-kind-option-icon">▦</span>
|
||||
<span className="a4-kind-option-title">Character matrix</span>
|
||||
<span className="a4-kind-option-desc">
|
||||
80 × 66 character grid for a dot-matrix printer. Frames and lines
|
||||
drawn with box characters.
|
||||
</span>
|
||||
</button>
|
||||
<button className="a4-kind-option" onClick={() => onChoose('a4')}>
|
||||
<span className="a4-kind-option-icon">📄</span>
|
||||
<span className="a4-kind-option-title">A4 page</span>
|
||||
<span className="a4-kind-option-desc">
|
||||
Free millimetre positioning for a laser printer or Save as PDF.
|
||||
Any installed font.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default A4KindChooser;
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Page setup dialog — a modal, not a docked panel, because it is opened
|
||||
occasionally and would otherwise compete with the inspector for the left rail. */
|
||||
|
||||
.a4-page-setup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.a4-page-setup {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: 340px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.a4-page-setup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.a4-page-setup-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a4-page-setup-close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--fs-2xl);
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.a4-page-setup-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a4-page-setup-body {
|
||||
padding: var(--space-2) var(--space-5) var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.a4-page-setup-dims,
|
||||
.a4-page-setup-hint {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-1-5);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.a4-page-setup-dims {
|
||||
color: var(--accent);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user