Compare commits

...

8 Commits

78 changed files with 7658 additions and 2079 deletions
+88
View File
@@ -0,0 +1,88 @@
# CSS design tokens — typography, spacing, and full colour migration
## Context
`src/styles/tokens.css` already defines semantic colour/radius/shadow tokens for
two themes (`classic`, `cockpit`). But:
- there are no font or spacing tokens: ~25 distinct font-sizes (7px…40px, px and
rem mixed), 9 font-family declarations, ~25 distinct padding/margin/gap values;
- about half the stylesheets still hardcode colours (`#667eea` ×88, `#333` ×42 …),
so those screens ignore the theme — they stay light under `cockpit`;
- `UserManager.jsx` styles itself entirely with inline `style={{}}`.
Goal: every UI value (colour, font, size, weight, spacing) comes from a token, so
the look is changed in one file. Report *output* keeps fixed values.
## What stays fixed (not tokenised)
Printed/rendered report output must look identical regardless of screen theme:
- `ReportEditor/a4/a4Elements.css`, `a4/print/print.css`, `ReportEditor/print/print.css`,
`ReportEditor/elements.css` (text-mode report elements on the page)
- colours in A4 element JSX (`a4/elements/*`), `calibrationReport.js`, `sampleData.js`, tests
- the "paper" surface itself (white page in `A4Canvas.css` / `EditorCanvas.css`)
The report **editor chrome** around it (toolbar, inspectors, config panel, character
palette, kind chooser, page setup, selection handles/guides) *does* get tokens.
Also left alone: `components/concepts/ScalesAppCyberpunk.css` (standalone mockup
with its own private vars). `DataDisplay.jsx/.css` is not imported anywhere — dead
code, flagged, not touched.
## New tokens (in `:root` only — theme-independent, both themes share them)
```css
/* Type */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Courier New', Consolas, monospace;
--fs-xs: 11px; --fs-sm: 12px; --fs-base: 13px; --fs-md: 14px;
--fs-lg: 16px; --fs-xl: 18px; --fs-2xl: 22px; --fs-3xl: 28px;
--fs-display: 32px; /* big figures: the weight reading etc. */
--fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700;
/* Spacing — 4px grid with the half-steps this UI actually uses a lot (6, 10) */
--space-0-5: 2px; --space-1: 4px; --space-1-5: 6px; --space-2: 8px;
--space-2-5: 10px; --space-3: 12px; --space-4: 16px; --space-5: 20px;
--space-6: 24px; --space-8: 32px; --space-10: 40px;
```
A theme may later override any of these (e.g. a larger `--fs-display` for cockpit).
**Snapping rule:** off-scale values snap to the nearest step (5→4/6, 7→6/8,
14→12/16, 15/17→16, 20→22 for font, rem values converted at 16px). Expect ≤2px
drift in places — that's the consistency we're buying. Values that are
geometry rather than rhythm (1px borders, icon/checkbox sizes, fixed widths,
`letter-spacing`) stay literal.
**Colour mapping** for stray literals (add a token only if nothing fits):
`#667eea`→`--accent`, `#764ba2` gradients→`--accent-gradient`,
`#333/#2c3e50`→`--text`, `#555/#666/#888/#999`→`--text-muted`,
`#ddd/#e0e0e0/#dee2e6`→`--border`, `#f5f5f5/#fafafa/#f0f0f0`→`--surface-sunken`/`--surface-hover`,
`white/#fff`→`--surface`, `#fee/#c33`→`--danger-soft`/`--danger`, greens/oranges→`--success*`/`--warning*`.
Likely one new token: `--backdrop` (modal overlay dim).
## Phases (one commit each; done via `/dispatch` → react-expert-dev)
1. **Tokens** — add the block above to `tokens.css`; `index.css` body uses
`--font-sans`, `code` uses `--font-mono`.
2. **Screens already on colour tokens** — swap font/spacing literals:
`Main.css`, `ScaleMonitor.css`, `Header.css`, `History.css`,
`ExtraCombinationsTable.css`, `App.css`, `ReportPreviewModal.css`.
3. **Hardcoded screens** — colours + fonts + spacing:
`NomenclatureManager.css`, `NomenclatureUI/*.css` (ManagementOverlay, EntryForm,
Dropdown, FormOverlay), `LicenseManager.css`, `Login.css`, `ChangePasswordOverlay.css`.
`UserManager.jsx`: move inline styles into a new `UserManager.css` (reuse existing
Main.css classes like `.extra-field`, `.vehicle-form-actions` where they already fit).
4. **Report editor chrome** — `ReportEditor.css`, `Toolbar.css`, `ObjectInspector.css`,
`ConfigPanel.css`, `CharacterPalette.css`, `EditorCanvas.css` (non-paper parts),
`a4/A4Canvas.css` (non-paper), `A4Inspector.css`, `A4PageSetup.css`,
`A4KindChooser.css`, `A4ReportEditor.css`; chrome colours in `A4Canvas.jsx` /
`A4Inspector.jsx` / `ObjectInspector.jsx` (guides, handles) → `var(--…)`.
## Verification
- After each phase: grep for `#[0-9a-f]{3,6}`, `font-size: *[0-9]`, px spacing in the
touched files → only allowed literals remain.
- End state: hex colours exist only in `tokens.css`, the "fixed" report files, and the
concept page.
- `npm run build` passes.
- Visual: screenshots (Playwright, saved to `.claude/screenshots/`) of main screen,
nomenclature manager, login, licence, user manager, both report editors — in
`classic` and `cockpit`, before and after. Classic should look the same (±snapping);
cockpit should now be dark on the previously-light screens.
- Test suite only when you say "ready for testing".
+451
View File
@@ -0,0 +1,451 @@
# Finalize-on-print, History view, and unified report preview modal
## Context
Today, the moment a vehicle's second weighing lands, the backend silently
computes `net` and assigns a `doc_number` (`_maybe_finalize`,
[api/views.py:134-141](backend/api/views.py#L134-L141)) — but nothing in the
UI reflects this as an event. The vehicle just sits in the active list
forever, indistinguishable from an in-progress one except that its action
column now shows Print/Design instead of Measure.
The user's actual workflow (matching their existing Delphi weighing software,
and the problem it has that they want to avoid repeating): once a truck is
weighed twice, the operator opens a report preview, prints or downloads the
notice, and the truck should then leave the active list — because it's done,
and a busy station's list should only show trucks still being processed. If a
print physically fails (paper jam, printer offline — a real, frequent
occurrence per the user, and one the browser's `window.print()` can never
report back on, confirmed via `afterprint`'s fundamental limitation), the
operator needs to get back to that vehicle's report without digging through a
separate/hidden screen. A **History** view listing finalized vehicles, with
the same print/download controls available there, is exactly that recovery
path — and it becomes the *only* path back once a vehicle leaves the active
list, so it must be trivial to reach, not buried.
This session's exploration surfaced two things that must be fixed as part of
this work, not deferred:
1. **`driver_name` is dead on arrival.** The live vehicle API is
`api/views.py` + `api/serializers.py` (registered in
`api/urls.py`) — NOT `vehicles/views.py`/`vehicles/serializers.py`, which
are orphaned (not routed) and contain the `driver_name`-required work from
the previous session. `driver_name` is missing entirely from
`VehicleSerializer.Meta.fields` in the real, live
`api/serializers.py:165`. The frontend's create-vehicle form already sends
`driver_name`, but the live backend silently drops it on the floor.
2. **The frontend never removes anything from `vehicles`, and SSE has no
working delete path.** `NomenclatureContext.handleSSEUpdate`
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432))
only handles `insert`/`update`; a `delete`/`archive` operation is silently
dropped even though the backend's `operation_map` already produces
`'delete'` for a `'deleted'` action. Any "vehicle leaves the list in real
time, on every open tab" behavior needs this added — it is not
pre-existing plumbing we can just call.
## Decisions made this session (do not re-litigate)
- **driver_name**: nullable at the DB level (already migrated), **required on
create** at the serializer level — fix belongs in `api/serializers.py`
(the live one), same validation approach already proven correct in the
orphaned `vehicles/serializers.py` (object-level `validate()`, gated on
`self.instance is None`, because a per-field `validate_driver_name` hook
was tested and found to skip validation when the key is omitted entirely).
- **When a vehicle leaves the active list**: on the operator's explicit
**Print or Download PDF click** inside the new preview modal — not
automatically the moment `net`/`doc_number` are assigned server-side. The
user was explicit: "when press it - the truck is given a document number,
disappears from list, the preview opens with print/download buttons."
Concretely: the modal opens as soon as Print Notice is clicked (same
trigger as today), and the archive+doc_number-visible transition happens
when the operator acts inside that modal (Print or Download PDF), not on
the initial open. Opening the modal must not, by itself, remove the truck —
an operator who opens it and then closes it without printing must find the
truck still there, still able to re-measure if needed.
- **Preview modal layout** (replaces `ReportPreviewModal`'s current bare
preview-only body): top bar with a matrix/A4 kind switcher on the left and
Download PDF on the right; body renders the selected kind's preview; footer
bar with centered Print and Close buttons. **The switcher is real, not a
placeholder**: `notice_report` can have two actual saved `Report` rows
(one `kind='matrix'`, one `kind='a4'`) side by side — confirmed the model
already allows this (`name` has no uniqueness constraint) — and the
switcher toggles between whichever of the two the operator has designed.
If only one kind has been saved, the other option shows a disabled/empty
state pointing at Design Notice for that kind. Default kind shown on open =
the kind that was open/used last, or the first one that exists.
- **Download PDF: REVERSED from an earlier draft of this plan** — no
server-side rendering. Originally scoped as Playwright (Python) driving
headless Chromium; abandoned once an existing plan
([a4-report-designer.md](.claude/plans/a4-report-designer.md), §"Why
Puppeteer is deferred (not rejected)") surfaced two concrete costs specific
to this deployment that the earlier draft hadn't weighed:
1. **Fonts.** A4 notices print with whatever font is installed on the
*operator's station* — that's the whole point of using
`window.print()`/`@page` CSS instead of a PDF library (see that same
plan's "Why browser printing, not a PDF library"). Server-side
rendering uses the *server's* fonts instead. At this project's actual
reference deployment (one server, ten stations, per
[[deployment-windows-installer]]), that's the wrong machine — a
server-rendered PDF could visibly mismatch what prints at the station,
forever, unless fonts are duplicated and kept in sync server-side.
2. **Puppeteer/Playwright earns its cost when a PDF must be produced with
no human in the loop** (auto-archiving, emailed tickets, batch jobs).
Download PDF has an operator clicking a button and waiting — exactly
the case `window.print()` → "Save as PDF" already covers, today, with
zero new services and zero font-mismatch risk.
**Decided approach**: Download PDF triggers the SAME `window.print()` flow
already used for Print (`printA4Report` for a4, the new print-root-append
flow from Phase C for matrix), just labelled/framed toward the "Save as
PDF" destination rather than physical printing. No Playwright, no
Puppeteer, no new backend endpoint, no offline-installer packaging cost.
This also means Phase D as originally scoped (a new PDF microservice) is
**not needed** — see the rewritten Architecture section below.
- **Print button** in the modal (and Download PDF, per the reversal above)
both use today's `window.print()`/`printA4Report()` flow for A4, and the
Phase C print-root-append flow for matrix. No further backend work.
- **History view**: a new page/route, reachable from the main nav (not
hidden), listing finalized vehicles (those with `doc_number` set) — most
recent first. Each row opens the same unified preview modal used from the
main weighing screen, so reprint/redownload is the identical component and
code path, not a second implementation.
- **Nothing is ever deleted.** Explicit, standing rule for this feature and
beyond: a vehicle record is never destroyed. There are three real states —
**in progress** (active list), **completed** (History, has `doc_number`),
and **voided** (History, flagged but still fully visible/queryable) — and
moving between them is always a flag/field change, never a `DELETE`. This
also means the orphaned `vehicles/views.py`'s `perform_destroy`
([confirmed at api/views.py's pattern](backend/api/views.py) — check
whether `VehicleViewSet` exposes a working DELETE at all; if it does, it is
now out of step with this rule and should be disabled, flagged to the user
rather than silently left reachable).
- **Void ("сторно"/reversal)**: an operator-triggered action available **only
from History, only on an already-completed record** — not on an
in-progress vehicle, and not a bulk/automatic thing. Marks a completed
weighing as voided when it turns out to be wrong (wrong truck, bad
measurement, mistake caught after printing) — the record, its doc_number,
and its full weighing history stay intact and visible in History,
permanently flagged, distinguishable from a normal completed record at a
glance (e.g. a visual badge, per how `.vehicle-status--*` already
distinguishes states in the hero bar,
[Main.css:189-203](frontend/src/components/Main.css#L189-L203)).
- **`vehicle_number` uniqueness must change.** Today it's a plain
`unique=True` column ([vehicles/models.py:11](backend/vehicles/models.py#L11))
— global, forever. Combined with "nothing is ever deleted," this breaks the
very next time a real truck (plate reused constantly) returns to the scale
after its first weighing completes: creating the new active record would
fail on a uniqueness violation. Decided fix: **unique only among
in-progress vehicles** — a truck can't be on the scale twice at once, but
its plate can appear any number of times across History. Implemented as a
Django partial `UniqueConstraint`, not a plain column constraint:
```python
class Meta:
constraints = [
models.UniqueConstraint(
fields=['vehicle_number'],
condition=models.Q(doc_number__isnull=True),
name='unique_active_vehicle_number',
),
]
```
(Django 6.0, confirmed installed — `condition=` on `UniqueConstraint` has
been supported since Django 2.2, compiles to a PostgreSQL partial unique
index, which this project's Postgres backend fully supports.) Requires
dropping the existing `unique=True` on the field itself and adding this
constraint instead — a real migration, not a no-op. Confirm during
implementation whether "in-progress" should be defined as
`doc_number__isnull=True` alone, or also exclude voided-but-somehow-
doc_number-less edge cases — should not arise if void is only ever
triggered on already-completed (doc_number-bearing) records, per the
Void scope decision above, but worth a sanity check against real data.
## Architecture
### Backend
**1. Fix the live serializer** — `backend/api/serializers.py`:
- Add `'driver_name'` to `VehicleSerializer.Meta.fields` (currently missing
at line 165).
- Add the required-on-create `validate()` override, ported from the pattern
already proven in the orphaned `vehicles/serializers.py`:
```python
def validate(self, attrs):
if self.instance is None and not attrs.get('driver_name'):
raise serializers.ValidationError({'driver_name': 'This field is required.'})
return attrs
```
(If `VehicleSerializer` already defines a `validate()`, merge into it
rather than adding a second one.)
- Leave the orphaned `vehicles/` app alone — it's unrouted dead code, not
this task's problem to clean up (flag to the user separately; don't delete
without being asked, per project convention).
**2. No PDF endpoint needed — REVERSED, see the Download PDF decision above.**
No new backend endpoint, no Playwright, no server-side rendering. Phase D on
the backend side is a no-op; everything below is frontend-only.
**3. Print-intent signal for both the modal's Print AND Download PDF
buttons**: both now trigger the *existing* client flow
(`window.print()`/`printA4Report` for a4, the Phase C print-root-append flow
for matrix — Download PDF is the same call, just aimed at the "Save as PDF"
destination the operator picks in that same dialog), which the backend can't
observe directly.
Add a lightweight endpoint, e.g. `POST /api/vehicles/{id}/mark-notice-printed/`,
called by the frontend right when the operator clicks Print in the modal (not
waiting on `afterprint`, since that signal is untrustworthy per this
session's earlier discussion) — its only job is to broadcast the SSE removal
event (below). It does not need to change any DB field; `doc_number` already
being set is the source of truth for "this vehicle belongs in History, not
the active list." This keeps "print succeeded" and "should leave the active
list" cleanly decoupled, exactly as discussed: a failed physical print still
correctly moves the truck to History (where it's one click to reprint), it
does not silently strand it in a state nobody can find.
**4. SSE removal broadcast**: wherever the "moved to history" signal fires
(the `mark-notice-printed` endpoint, and/or the new PDF endpoint on
successful generation), call
`sse_broadcast_update('vehicle', 'archived', serializer.data)` — a new,
distinct action string (not reusing `'deleted'`, since the vehicle record
itself isn't deleted, only its presence in the *active* list changes) — see
frontend section for how this action is consumed. Using
[sse_broadcast_update](backend/scalesapp/sse.py#L26-L58), same call shape
already used by `perform_create`/`perform_update`.
**5. Filter the active-list endpoint** (optional but recommended for
correctness/scale): today `VehicleViewSet.queryset` returns all vehicles
unconditionally. Once History exists, the main screen's vehicle list should
probably query only non-finalized vehicles server-side too (e.g.
`?doc_number__isnull=true`), not rely purely on client-side SSE-driven
removal — SSE gives real-time updates for tabs already open, but a **fresh
page load** should not have to fetch every historical vehicle ever weighed
just to filter them out client-side. Add a query param the frontend's main
list fetch already passes (`NomenclatureContext`'s vehicle endpoint config,
[NomenclatureContext.jsx:9](frontend/src/contexts/NomenclatureContext.jsx#L9)),
and a separate History fetch that explicitly requests
`?doc_number__isnull=false`, ordered newest-first (`-net_date` or similar).
**6. `is_void` field + partial-uniqueness migration** — the `Vehicle` model
itself lives at `backend/vehicles/models.py` regardless of which app's
views/serializers are live (there is only ONE `Vehicle` model — `vehicles/`
being "orphaned" refers only to that app's OWN `views.py`/`serializers.py`,
not the model, which `api/views.py` imports and uses directly). So this
migration correctly targets `backend/vehicles/models.py`, but the
`@action`/serializer changes below belong in `api/views.py`/`api/serializers.py`
(the live ones), matching how `driver_name` and everything else in this plan
is split: model in `vehicles/`, live API surface in `api/`.
- Add `is_void = models.BooleanField(default=False)`, plus
`void_date`/`void_user`/`void_reason` following the same
date/user/note provenance pattern already used for `tare`/`gross`/manual
entries elsewhere on this model (e.g. `tare_manual_user`,
`tare_manual_date`, `tare_manual_note`,
[vehicles/models.py:19-22](backend/vehicles/models.py#L19-L22)) — reuse
that established shape rather than inventing a different one for void.
- Replace `vehicle_number = models.CharField(max_length=15, unique=True)`
with the plain field (drop `unique=True`) plus the partial
`UniqueConstraint` from the decision above, in one migration.
- New `@action` on `VehicleViewSet`, e.g.
`POST /api/vehicles/{id}/void/` (mirroring `measure`'s permission-gated
pattern) — sets `is_void`, `void_date`, `void_user`, accepts a required
`reason` in the request body (store it; an operator voiding a printed
document should always leave a reason, same spirit as
`gross_manual_note`/`tare_manual_note` already require context for a
manual override). Rejects (400) if `doc_number` is not set — void only
applies to completed records, per the scope decision above. This does NOT
set `doc_number` to null or touch `net`/`tare`/`gross` — everything about
the original weighing stays exactly as it was, only `is_void` changes.
Broadcasts `sse_broadcast_update('vehicle', 'voided', serializer.data)` so
any open History view updates live too.
- `VehicleSerializer` needs `is_void`/`void_date`/`void_reason` added to
`Meta.fields` (read-only except through the dedicated void action).
### Frontend
**1. `NomenclatureContext.jsx`** — add a `delete`/`archive` branch to
`handleSSEUpdate`'s switch
([NomenclatureContext.jsx:407-432](frontend/src/contexts/NomenclatureContext.jsx#L407-L432)),
handling the new `'archived'` action (mapped through `operation_map` on the
backend, or handled as a raw fallback string if not added to that map — check
which is simpler when implementing) by removing the vehicle from the
in-memory `vehicles` array. This is the one piece of "real-time removal"
plumbing that doesn't exist anywhere today and is the crux of making
multi-station "truck vanishes everywhere at once" actually work.
**2. Unified `ReportPreviewModal`** — restructure
[ReportPreviewModal.jsx](frontend/src/components/ReportPreviewModal.jsx) (and
its `.css`) into three regions instead of the current bare header+body:
- **Top bar**: left = matrix/A4 toggle, switching between two genuinely
separate stored `Report` rows sharing the same `name='notice_report'` but
different `kind`. Confirmed this needs no schema change: `Report.name` has
**no uniqueness constraint**
([api/models.py:45-75](backend/api/models.py#L45-L75)), so a matrix row and
an a4 row can already coexist under the same name — nothing stops it today.
The gap is entirely on the fetch/lookup side:
- `fetchNoticeReport()` ([Main.jsx:357-360](frontend/src/components/Main.jsx#L357-L360))
currently does `.find(r => r.name === 'notice_report')`, returning
whichever row the API lists first — it has no way to ask for "the matrix
one" vs "the a4 one." Change it to accept a `kind` argument and filter
accordingly (or add a backend query param, `?name=notice_report&kind=a4`,
since `ReportViewSet` already has `filterset_fields = ['name', 'kind']`
— the backend filter already exists, this is a client-side fix only).
- The modal fetches BOTH kind-variants on open (two lookups, or one list
call filtered by name only, then split client-side by `kind`), so the
switcher can toggle without a re-fetch. If one kind doesn't exist yet
(operator never designed an A4 version), that switcher option is
disabled/shows an empty state with a link to Design Notice for that kind.
- `handleDesignNotice`/`handleDesignAs`
([Main.jsx:403-449](frontend/src/components/Main.jsx#L403-L449)) already
let an operator explicitly open either editor regardless of what's saved
(`handleDesignAs('matrix'|'a4')`) — this already supports creating the
*other* kind's report under the same name; the Report Editor's own save
path (`handleSave`,
[ReportEditor.jsx:276-297](frontend/src/components/ReportEditor/ReportEditor.jsx#L276-L297))
does a plain `POST` when `reportId` is unset, so saving a second,
different-kind `notice_report` is already a supported write path with no
backend changes needed there either.
Right = Download PDF button — **REVERSED, see the top-level decision
above**: no backend endpoint, no blob download. Triggers the same
`window.print()`/`printA4Report` (a4) or print-root-append (matrix) flow
as the Print button, so the operator reaches PDF via "Save as PDF" in that
same dialog, using the station's own installed fonts. (Phase C shipped
this button as a disabled placeholder before this reversal was decided —
Phase D's remaining frontend work is wiring it to the real print flow
instead of leaving it disabled, plus the `MatrixPrintDocument` work above.)
- **Body**: existing `EditorCanvas`/`DataProvider`/`BandProvider` preview for
matrix; for A4, mount `PrintDocument` (confirmed stateless/reusable,
[PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
scaled into the modal body instead of only appending it to `document.body`
for `window.print()` — needs a scaled/contained rendering mode, not the
full-bleed print-root mounting `printReport.jsx` uses today.
- **Footer bar**: centered Print + Close. Print triggers the kind-appropriate
existing print flow (`printA4Report` for a4; for matrix, there is currently
no equivalent "just print this" path — `ReportPreviewModal` today is
preview-only with no print button at all, so a matrix print path needs to
be added, likely also via `window.print()` on the rendered preview DOM, or
reusing the same print-root-append pattern `printReport.jsx` established).
Then calls the new `mark-notice-printed` endpoint.
- Replace `Main.jsx`'s current branching logic
([handlePrintNotice, lines 375-398](frontend/src/components/Main.jsx#L375-L398))
so BOTH kinds open this one modal (today only matrix does; a4 skips the
modal entirely and goes straight to `window.print()`) — this is the biggest
behavioral change to `handlePrintNotice`: it becomes "always open the
modal," full stop, with the modal itself deciding what Print/Download do
based on the loaded report's kind.
**3. New History page** — new route (e.g. `/history`) added alongside the
existing routes in
[App.jsx](frontend/src/App.jsx) (inside the same provider nest, following the
existing route-gating pattern used for admin-only pages). Fetches finalized
vehicles (`?doc_number__isnull=false`, newest first) independently of the
live `vehicles` array (History should NOT depend on `NomenclatureContext`'s
in-memory list, since that list is specifically the *active* set now).
List each with vehicle number, doc number, net weight, date, and a
completed/voided badge (`is_void`); clicking a row opens the same unified
`ReportPreviewModal`, passing that vehicle's data exactly as `Main.jsx` does
today via `vehicleReportData`
([Main.jsx:313-334](frontend/src/components/Main.jsx#L313-L334) — this
transform logic is reusable, likely worth extracting to a shared helper
rather than duplicating in the new History component). Print/Download PDF
stay available on a voided row too — voiding never removes the ability to
see or reproduce the original document, it only flags that the weighing
itself shouldn't be treated as valid.
- **Void button**: on a completed (non-voided) row, a distinct action from
opening the preview — requires a reason (matches the backend's required
`reason` field), then calls the new `/void/` endpoint. On success, the row
updates in place to show the voided badge; it does not disappear or move
anywhere (History is a flat list of everything that ever completed,
voided or not). Confirm with a dialog before voiding — this is meant to be
rare and deliberate, matching the "сторно/reversal" concept's own
seriousness (it's correcting the record, not casually undoable further).
- A nav entry for History (in `Header.jsx`, alongside whatever nav already
exists) — must be easy to find, per the whole point of this feature being
"don't bury the recovery path."
## Open questions to resolve during implementation
Phase D, resolved: no Playwright/Puppeteer, no server-side PDF endpoint (see
the Download PDF decision above). The matrix print path question is already
resolved too — Phase C built it (print-root-append, following
`printReport.jsx`'s pattern, confirmed working).
**Remaining open item, genuinely new scope surfaced while designing Phase D**:
1. **Matrix has no `PrintDocument`-equivalent.** A4's `PrintDocument.jsx`
([print/PrintDocument.jsx](frontend/src/components/ReportEditor/a4/print/PrintDocument.jsx))
is a small (107-line), stateless, non-interactive component that reads
`layout()`'s pure `pages[]` output and renders plain positioned HTML — no
hooks, no drag/select/delete callbacks. Its own doc comment already
anticipated exactly this kind of reuse: *"DELIBERATELY DUMB... that is
what lets a future Puppeteer service consume this exact markup."* Matrix
has no equivalent: its preview goes through `EditorCanvas`
([EditorCanvas.jsx](frontend/src/components/ReportEditor/EditorCanvas.jsx),
682 lines), the SAME interactive editor component used for actual
editing, gated by a `previewMode` prop — confirmed by reading it that
`previewMode` is a flag threaded through the interactive tree, not a
separate rendering path; every element still gets full
`onSelect`/`onUpdate`/`onDelete`/`onDragStart` callback props even in
preview mode. Phase C's matrix print-root flow works today only because
it reuses this same `EditorCanvas`-in-`previewMode` machinery — it works,
but it's borrowing an editor component for a job a pure renderer should
do.
- **Decided**: build a `MatrixPrintDocument`-equivalent as part of this
phase — a pure, non-interactive component mirroring what
`PrintDocument.jsx` already does for A4, reading matrix `elements` +
resolved band/data output and emitting plain markup. This is real,
bounded new work (not a redesign of anything existing), and it's a
prerequisite for the matrix print/preview path to reach the same
quality bar A4 already has — it does NOT unlock server-side PDF
generation (that's still explicitly out of scope, per the reversal
above); it's purely about correctness/simplicity of the existing
client-side matrix print flow, replacing "borrow the editor" with "use
a real preview renderer," same shape as A4.
## Verification
1. Create a vehicle without `driver_name` via the API directly (curl/Postman)
— confirm it's now rejected with a 400 and the field-specific error,
where today it would silently succeed with `driver_name` dropped.
2. Weigh a vehicle twice (tare + gross) in the browser — confirm it still
shows in the active list (finalization alone must not remove it).
3. Click Print Notice — confirm the unified modal opens (for BOTH a matrix-
and an a4-kind `notice_report`, test each), with top bar (kind
switcher + Download PDF), body preview, footer (Print + Close centered).
4. Click Close without printing — confirm the vehicle is still in the active
list (opening the modal alone must not archive).
5. Click Print — confirm `window.print()`/`printA4Report` still works exactly
as before, AND the vehicle disappears from the active list within ~1s on
the SAME tab and on a SECOND open tab (real-time SSE removal across
stations).
6. Click Download PDF instead — confirm it opens the same `window.print()`
dialog as Print (with "Save as PDF" available as a destination, per the
reversed decision — no direct blob download, no bypass of the OS dialog),
and the vehicle is similarly removed from the active list once triggered.
7. Open History — confirm the just-archived vehicle appears, newest first,
with correct doc_number/net/date.
8. Click that History row — confirm the same modal opens and Print/Download
both work again (the reprint path this whole feature exists to guarantee).
9. Confirm a vehicle still mid-weighing (no `gross` yet) never appears in
History.
10. **Reuse a plate**: complete a weighing for `B7898TP`, confirm it archives
to History, then create a brand-new active vehicle also named `B7898TP`
— confirm this succeeds (no uniqueness error), proving the partial
constraint is scoped correctly. Then try creating a SECOND simultaneous
active `B7898TP` while the first is still in progress — confirm this is
rejected (proving uniqueness is still enforced among active records).
11. From History, void a completed record without providing a reason —
confirm it's rejected. Void it with a reason — confirm the row updates
in place with a voided badge, doc_number/net/tare/gross are unchanged,
and Print/Download still work on it afterward.
12. Attempt to void an active (non-completed) vehicle directly via the API —
confirm it's rejected (void is completed-records-only, per scope).
13. Confirm nothing in this feature ever issues a DELETE against a vehicle.
**Done, not just checked**: `VehicleViewSet.destroy()` (the LIVE one in
`api/views.py` — found reachable via `IsAuthenticated` alone, worse than
the orphaned `vehicles/views.py` this item originally worried about) now
unconditionally returns `405` pointing at `/void/` instead. Verify with a
direct `DELETE /api/vehicles/{id}/` call that it's rejected and the
vehicle still exists afterward.
14. **Matrix print quality**: with the new `MatrixPrintDocument`, print (and
Download-as-PDF-via-print) a matrix `notice_report` — confirm the output
matches what the old `EditorCanvas`-in-`previewMode` flow produced
(no regression), and confirm the print/preview path no longer carries
editor-only affordances (no residual selection/drag artifacts, since a
non-interactive component can't have any).
@@ -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
+96
View File
@@ -0,0 +1,96 @@
# UI Theming — switchable themes + "cockpit" dark skin
## Context
The current UI is functional but flat and generic. The user wants to try a
different look (reference: a dark "cockpit" dashboard mockup — deep navy
surfaces, cyan/amber accents, glow on the live reading, photo banner) **without
losing the ability to switch back**.
Today there is no theming layer at all: **0 CSS custom properties, ~430
hardcoded colours across 16 CSS files**. So the switch has to be built before
any new skin can exist.
Key finding that reduces scope: `Main.jsx` **already has the card structure**
the mockup shows — `.weighing-section` containing `.weight-card` elements for
Tare / Gross / Scale / Net, each with header, value and meta. No restructuring
of the detail panel is needed; this is a styling job.
## Approach
Token layer + `data-theme` attribute on `<html>`. Two themes:
- `classic` — reproduces today's look exactly (the safe default)
- `cockpit` — the new dark skin
### 1. Token layer
New `frontend/src/styles/tokens.css`, imported once in `index.css`:
```css
:root, :root[data-theme="classic"] {
--bg, --surface, --surface-raised, --border,
--text, --text-muted, --accent, --accent-contrast,
--success, --warning, --danger,
--radius, --shadow-card, --glow-accent
}
:root[data-theme="cockpit"] { /* same names, dark values */ }
```
Token NAMES are semantic (`--surface`), never literal (`--navy`), so a third
theme is one block.
### 2. Convert existing CSS to var()
Mechanical replacement of hardcoded colours. `classic` values are lifted from
the current CSS so the default theme is **visually unchanged** — that is the
acceptance test.
### 3. Theme switch
- `ThemeContext` (mirrors the existing `AuthContext`/`NomenclatureContext`
pattern in `frontend/src/contexts/`)
- Sets `data-theme` on `document.documentElement`
- Persists to `localStorage`, wrapped in try/catch (private mode can throw)
- Toggle control in `Header.jsx`
- **Default: `classic`** — nothing changes for operators unless they opt in
### 4. Cockpit theme + banner
Dark palette, raised card surfaces, cyan accent, glow on the live reading,
monospace weight readouts. Banner strip behind the vehicle title in
`.vehicle-detail` — image asset TBD (CSS gradient placeholder until the user
supplies one). Banner is cockpit-only and must collapse on short viewports;
this may run on a weigh-station monitor.
## Staging
Deliberately two stages so a wrong palette is caught after 3 files, not 16.
**Stage 1** — tokens, ThemeContext, header toggle, and convert:
- `frontend/src/components/Main.css` (68 colours)
- `frontend/src/components/Header.css` (17)
- `frontend/src/components/ScaleMonitor/ScaleMonitor.css` (47)
- `frontend/src/index.css`, `frontend/src/App.css`
→ user reviews both themes on the main weighing screen.
**Stage 2** — remaining 13 files, once the direction is approved:
NomenclatureManager (79), NomenclatureManagementOverlay (54),
LicenseManager (45), ChangePasswordOverlay (22), ExtraCombinationsTable (20),
DataDisplay (19), Login (15), NomenclatureEntryForm (14),
NomenclatureDropdown (14), FormOverlay, ReportPreviewModal.
## Critical files
- `frontend/src/styles/tokens.css` (new)
- `frontend/src/contexts/ThemeContext.jsx` (new)
- `frontend/src/components/Header.jsx` — toggle control
- `frontend/src/App.jsx` — wrap in ThemeProvider
- `frontend/src/components/Main.css` — the main surface
Report editor CSS (`components/ReportEditor/`) is **out of scope**: the A4
designer renders a paper page that must stay white regardless of theme.
## Verification
1. `npx vitest run` — 201 tests still pass
2. Classic theme vs `git stash` screenshot: **visually identical**
3. Toggle switches instantly, no reload; survives refresh (localStorage)
4. Cockpit: check contrast on the live reading, balanced/unbalanced states,
and that the banner collapses on a short viewport
5. Real browser check of both themes on the main weighing screen
@@ -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.
+11 -3
View File
@@ -159,22 +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)
@@ -208,6 +211,11 @@ 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:
View File
+105
View File
@@ -0,0 +1,105 @@
from datetime import timedelta
from unittest import mock
from django.utils import timezone
from rest_framework.test import APITestCase
from api.models import User
from vehicles.models import Vehicle
STATION = {'station_key': 'test-station'}
class OneWeightTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user('op', password='x', can_measure=True)
self.other = User.objects.create_user('first', password='x')
self.client.force_authenticate(self.user)
p = mock.patch('api.views._station_allowed', return_value=True)
p.start()
self.addCleanup(p.stop)
p = mock.patch('api.views.sse_broadcast_update')
self.sse = p.start()
self.addCleanup(p.stop)
def url(self, v):
return f'/api/vehicles/{v.pk}/one-weight/'
def make(self, **kw):
return Vehicle.objects.create(vehicle_number='CB1234', driver_name='D', **kw)
def test_moves_tare_to_gross_and_finalizes(self):
when = timezone.now() - timedelta(hours=1)
v = self.make(tare=1500, tare_date=when, tare_user=self.other, tare_manual=True,
tare_manual_user=self.other, tare_manual_date=when, tare_manual_note='n')
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 200, r.content)
v.refresh_from_db()
self.assertEqual(v.gross, 1500)
self.assertEqual(v.gross_date, when)
self.assertEqual(v.gross_user, self.other)
self.assertTrue(v.gross_manual)
self.assertEqual(v.gross_manual_user, self.other)
self.assertEqual(v.gross_manual_date, when)
self.assertEqual(v.gross_manual_note, 'n')
for f in ('tare', 'tare_date', 'tare_user', 'tare_manual_user',
'tare_manual_date', 'tare_manual_note'):
self.assertIsNone(getattr(v, f), f)
self.assertFalse(v.tare_manual)
self.assertEqual(v.net, 1500)
self.assertEqual(v.net_user, self.user)
self.assertIsNotNone(v.net_date)
self.assertTrue(v.doc_number)
self.assertEqual(r.data['doc_number'], v.doc_number)
self.assertEqual(r.data['net'], 1500)
self.assertIsNone(r.data['tare'])
self.sse.assert_called_once()
self.assertEqual(self.sse.call_args.args[:2], ('vehicle', 'updated'))
def test_gross_already_set(self):
when = timezone.now()
v = self.make(gross=9000, gross_date=when, gross_user=self.other)
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 200, r.content)
v.refresh_from_db()
self.assertEqual((v.gross, v.gross_user, v.gross_date), (9000, self.other, when))
self.assertIsNone(v.tare)
self.assertEqual(v.net, 9000)
self.assertTrue(v.doc_number)
def _assert_400(self, v):
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 400)
self.assertIn('detail', r.data)
self.sse.assert_not_called()
def test_already_finalized(self):
self._assert_400(self.make(gross=5, net=5, doc_number='2026-1'))
def test_void(self):
self._assert_400(self.make(tare=5, is_void=True))
def test_no_weight(self):
self._assert_400(self.make())
def test_both_weights(self):
self._assert_400(self.make(tare=5, gross=10))
def test_permission_denied(self):
v = self.make(tare=5)
self.client.force_authenticate(self.other)
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 403)
v.refresh_from_db()
self.assertEqual(v.tare, 5)
self.assertIsNone(v.doc_number)
def test_station_not_licensed(self):
v = self.make(tare=5)
with mock.patch('api.views._station_allowed', return_value=False):
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 403)
def test_unknown_vehicle_404(self):
r = self.client.post('/api/vehicles/999999/one-weight/', STATION, format='json')
self.assertEqual(r.status_code, 404)
+136 -9
View File
@@ -2,6 +2,7 @@ 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, ScaleProtocol, License,
StationSlot, generate_doc_number)
@@ -11,6 +12,7 @@ from .serializers import (
NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
ScaleProtocolSerializer,
)
from django.db import transaction
from django.utils import timezone
from vehicles.models import Vehicle, ExtraCombination, combination_hash
from nomenclatures.models import Nomenclature, NomenclatureEntry
@@ -131,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:
@@ -159,9 +179,7 @@ def _assign_weighing(vehicle, value, user, manual=False, note=None):
setattr(vehicle, f'{prefix}_manual_note', note if manual else None)
def move(src_prefix, dst_prefix):
"""Move a recorded weighing to the other slot, carrying all its provenance."""
for suffix in ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note'):
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
_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.')
@@ -228,14 +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
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']
@@ -251,10 +275,17 @@ class VehicleViewSet(viewsets.ModelViewSet):
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):
@@ -321,6 +352,102 @@ class VehicleViewSet(viewsets.ModelViewSet):
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,
@@ -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",
),
),
]
+14 -1
View File
@@ -8,10 +8,11 @@ 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)
@@ -35,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}"
+7 -1
View File
@@ -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
-11
View File
@@ -1,14 +1,3 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@components/*": ["components/*"],
"@contexts/*": ["contexts/*"],
"@hooks/*": ["hooks/*"],
"@utils/*": ["utils/*"],
"@services/*": ["services/*"],
"@assets/*": ["assets/*"]
}
},
"include": ["src"]
}
+25 -25
View File
@@ -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);
}
}
+39 -7
View File
@@ -1,11 +1,14 @@
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';
@@ -16,6 +19,13 @@ 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);
// const { readings, isConnected, error } = useSerialData();
@@ -68,12 +78,15 @@ function AppContent() {
<LicenseProvider>
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
<Routes>
<Route path="/" element={<Main />} />
<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="/" />} />
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
<Route path="/scale-monitor" element={currentUser?.is_admin ? <ScaleMonitor /> : <Navigate to="/" />} />
<Route path="/license" element={currentUser?.is_admin ? <LicenseManager /> : <Navigate to="/" />} />
</Routes>
</Suspense>
</LicenseProvider>
@@ -87,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

@@ -1,31 +1,31 @@
.extra-combos {
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
.extra-combos h3 {
margin: 0 0 8px 0;
font-size: 12px;
color: #888;
font-weight: 600;
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: 6px 10px;
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
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: 12px;
margin-bottom: 8px;
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 #e0e0e0;
border: 1px solid var(--border);
border-radius: 6px;
}
@@ -35,17 +35,17 @@
}
.extra-combos-table-container::-webkit-scrollbar-track {
background: #f1f1f1;
background: var(--surface-hover);
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb {
background: #888;
background: var(--text-muted);
border-radius: 4px;
}
.extra-combos-table-container::-webkit-scrollbar-thumb:hover {
background: #555;
background: var(--text-muted);
}
.extra-combos-table {
@@ -54,19 +54,19 @@
}
.extra-combos-table thead {
background-color: #f8f9fa;
background-color: var(--surface-sunken);
position: sticky;
top: 0;
z-index: 1;
}
.extra-combos-table th {
padding: 0.4rem 0.75rem;
padding: var(--space-1-5) var(--space-3);
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
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;
@@ -77,10 +77,10 @@
}
.extra-combos-table td {
padding: 0.4rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
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;
}
@@ -90,7 +90,7 @@
}
.extra-combos-row:hover {
background-color: #f0f0ff;
background-color: var(--accent-soft);
}
.extra-combos-td-actions {
@@ -99,31 +99,31 @@
.extra-combos-delete-btn {
background: none;
border: 1px solid #dee2e6;
padding: 0 0.35rem;
border: 1px solid var(--border);
padding: 0 var(--space-1-5);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
font-size: var(--fs-md);
line-height: 1.4;
color: #999;
color: var(--text-muted);
}
.extra-combos-delete-btn:hover {
background-color: #dc3545;
border-color: #dc3545;
color: white;
background-color: var(--danger);
border-color: var(--danger);
color: var(--accent-contrast);
}
.extra-combos-stale {
color: #999;
color: var(--text-muted);
font-style: italic;
}
.extra-combos-empty {
text-align: center;
padding: 1rem !important;
color: #999;
padding: var(--space-4) !important;
color: var(--text-muted);
font-style: italic;
font-size: 0.85rem;
font-size: var(--fs-md);
}
-198
View File
@@ -1,198 +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 dropdown — the report editor button opens a choice of editors rather
than navigating straight to one, since the two are not interchangeable. */
.nav-dropdown {
position: relative;
display: flex;
}
/* Over the page, under the menu: any outside click closes it. */
.nav-dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 100;
}
.nav-dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 101;
min-width: 220px;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.nav-dropdown-menu button {
display: block;
width: 100%;
padding: 10px 14px;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: #333;
font-family: inherit;
}
.nav-dropdown-menu button + button {
border-top: 1px solid #eee;
}
.nav-dropdown-menu button:hover {
background: #f2f3fa;
}
.nav-dropdown-menu strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.nav-dropdown-menu span {
display: block;
margin-top: 2px;
font-size: 11px;
color: #777;
}
.header-right {
display: flex;
align-items: center;
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;
}
}
-134
View File
@@ -1,134 +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 [showEditorMenu, setShowEditorMenu] = useState(false);
const openEditor = (route) => {
setShowEditorMenu(false);
navigate(route);
};
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 && (
<div className="nav-dropdown">
<button
className="nav-button"
onClick={() => setShowEditorMenu(v => !v)}
title="Report Editor"
aria-haspopup="menu"
aria-expanded={showEditorMenu}
>
📝
</button>
{showEditorMenu && (
<>
{/* Click-away close, without a document-level listener. */}
<div
className="nav-dropdown-backdrop"
onClick={() => setShowEditorMenu(false)}
/>
<div className="nav-dropdown-menu" role="menu">
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
)}
{currentUser?.can_manage_entities && (
<button
className="nav-button"
onClick={() => setShowNomenclatureManager(true)}
title="Nomenclatures"
>
📋
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/users')}
title="User Manager"
>
👥
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/scale-monitor')}
title="Scale Monitor"
>
📡
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/license')}
title="Licence"
>
🔑
</button>
)}
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
<div className="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
</div>
<div className="avatar-container">
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
{getInitials(currentUser || {})}
</div>
<button className="logout-button" onClick={logout} title="Logout">
Logout
</button>
</div>
</div>
</div>
</header>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
export default Header;
+182
View File
@@ -0,0 +1,182 @@
.history {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-6) var(--space-5);
}
.history-title {
margin: 0 0 var(--space-4) 0;
color: var(--text);
}
.history-error {
padding: var(--space-2-5) var(--space-4);
margin-bottom: var(--space-3);
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-sm);
font-size: var(--fs-base);
}
.history-table-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.history-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-md);
}
.history-table th {
text-align: left;
padding: var(--space-2-5) var(--space-4);
background: var(--surface-hover);
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
.history-table td {
padding: var(--space-2-5) var(--space-4);
border-bottom: 1px solid var(--border);
color: var(--text);
}
.history-row {
cursor: pointer;
}
.history-row:hover {
background: var(--surface-hover);
}
.history-row:last-child td {
border-bottom: none;
}
.history-empty {
text-align: center;
color: var(--text-muted);
padding: var(--space-8) !important;
}
.history-badge {
display: inline-block;
padding: var(--space-1) var(--space-2-5);
border: 1px solid;
border-radius: 20px;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
letter-spacing: 0.03em;
white-space: nowrap;
}
.history-badge--ok {
color: var(--success);
border-color: var(--success);
background: var(--success-soft);
}
.history-badge--void {
color: var(--danger);
border-color: var(--danger);
background: var(--danger-soft);
}
.history-void-btn {
padding: var(--space-1) var(--space-3);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: var(--fs-sm);
cursor: pointer;
}
.history-void-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.history-void-inline {
display: flex;
flex-direction: column;
gap: var(--space-1-5);
min-width: 180px;
}
.history-void-inline input {
padding: var(--space-1-5) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
box-sizing: border-box;
}
.history-void-error {
color: var(--danger);
font-size: var(--fs-xs);
}
.history-void-actions {
display: flex;
gap: var(--space-1-5);
}
.history-void-actions button {
padding: var(--space-1) var(--space-2-5);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-hover);
color: var(--text);
font-size: var(--fs-sm);
cursor: pointer;
}
.history-pager {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
margin-top: var(--space-4);
}
.history-pager button {
min-width: 32px;
height: 32px;
padding: 0 var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: var(--fs-base);
cursor: pointer;
}
.history-pager button:hover:not(:disabled) {
border-color: var(--border-strong);
}
.history-pager button:disabled {
cursor: default;
opacity: 0.5;
}
.history-pager .history-page-btn--active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
opacity: 1;
}
.history-page-gap {
color: var(--text-muted);
padding: 0 var(--space-1);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import { buildVehicleReportData } from '../services/vehicleReportData';
import ReportPreviewModal from './ReportPreviewModal';
import api from '../services/api';
import './History.css';
const PAGE_SIZE = 100; // matches the backend's DRF PAGE_SIZE
export default function History() {
const { currentUser } = useAuth();
const { definitions, entries } = useNomenclatureData();
const [vehicles, setVehicles] = useState([]);
const [count, setCount] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// django-filter's OrderingFilter has no explicit `ordering_fields` set on
// VehicleViewSet, so it falls back to allowing any non-write-only
// serializer field - net_date included - meaning ?ordering=-net_date
// works server-side today. No client-side fallback sort is needed.
const [previewVehicle, setPreviewVehicle] = useState(null);
const [voidingId, setVoidingId] = useState(null);
const [voidReason, setVoidReason] = useState('');
const [voidError, setVoidError] = useState('');
const [isVoiding, setIsVoiding] = useState(false);
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
// Windowed page list (first, last, and a range around the current page)
// so the pager stays usable once History accumulates hundreds of pages -
// nothing in this feature ever deletes a vehicle, so unbounded growth is
// the expected long-run case, not an edge case.
const pageList = useMemo(() => {
const spread = 2;
const pages = new Set([1, totalPages]);
for (let n = page - spread; n <= page + spread; n++) {
if (n >= 1 && n <= totalPages) pages.add(n);
}
const sorted = [...pages].sort((a, b) => a - b);
const withGaps = [];
sorted.forEach((n, i) => {
if (i > 0 && n - sorted[i - 1] > 1) withGaps.push('...');
withGaps.push(n);
});
return withGaps;
}, [page, totalPages]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
api.get('/api/vehicles/', {
params: { 'doc_number__isnull': false, ordering: '-net_date', page },
})
.then(res => {
if (cancelled) return;
setVehicles(res.data.results || []);
setCount(res.data.count || 0);
})
.catch(err => {
if (cancelled) return;
setError(err.response?.data?.detail || 'Failed to load history');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [page]);
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const openPreview = (vehicle) => {
setPreviewVehicle(vehicle);
};
const previewData = useMemo(
() => previewVehicle ? buildVehicleReportData(previewVehicle, definitions, entries) : null,
[previewVehicle, definitions, entries]
);
const startVoid = (vehicle) => {
setVoidingId(vehicle.id);
setVoidReason('');
setVoidError('');
};
const cancelVoid = () => {
setVoidingId(null);
setVoidReason('');
setVoidError('');
};
const confirmVoid = async () => {
if (!voidReason.trim()) {
setVoidError('A reason is required to void a document.');
return;
}
if (!window.confirm('Void this document? This flags it permanently and cannot be undone casually.')) {
return;
}
setIsVoiding(true);
setVoidError('');
try {
const res = await api.post(`/api/vehicles/${voidingId}/void/`, { reason: voidReason.trim() });
// History has its own separate fetch/state (not NomenclatureContext's
// vehicles array), so the voided row is updated in place here from
// the API response rather than relying on the SSE 'voided' handler.
setVehicles(prev => prev.map(v => v.id === res.data.id ? res.data : v));
cancelVoid();
} catch (err) {
setVoidError(err.response?.data?.detail || 'Failed to void document');
} finally {
setIsVoiding(false);
}
};
return (
<div>
<div className="history">
<h2 className="history-title">History</h2>
{error && <div className="history-error">{error}</div>}
<div className="history-table-wrap">
<table className="history-table">
<thead>
<tr>
<th>Vehicle</th>
<th>Doc #</th>
<th>Net (kg)</th>
<th>Date</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="history-empty">Loading...</td></tr>
) : vehicles.length === 0 ? (
<tr><td colSpan={6} className="history-empty">No completed vehicles yet</td></tr>
) : (
vehicles.map(v => (
<tr key={v.id} className="history-row" onClick={() => openPreview(v)}>
<td>{v.vehicle_number}</td>
<td>{v.doc_number}</td>
<td>{v.net != null ? v.net : '—'}</td>
<td>{formatDate(v.net_date)}</td>
<td>
<span className={`history-badge ${v.is_void ? 'history-badge--void' : 'history-badge--ok'}`}>
{v.is_void ? 'Voided' : 'Completed'}
</span>
</td>
<td onClick={e => e.stopPropagation()}>
{currentUser?.can_edit_documents && !v.is_void && (
voidingId === v.id ? (
<div className="history-void-inline">
<input
type="text"
placeholder="Reason..."
value={voidReason}
onChange={e => setVoidReason(e.target.value)}
autoFocus
/>
{voidError && <div className="history-void-error">{voidError}</div>}
<div className="history-void-actions">
<button onClick={confirmVoid} disabled={isVoiding}>
{isVoiding ? 'Voiding...' : 'Confirm'}
</button>
<button onClick={cancelVoid} disabled={isVoiding}>Cancel</button>
</div>
</div>
) : (
<button className="history-void-btn" onClick={() => startVoid(v)}>
Void
</button>
)
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="history-pager">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
>
‹
</button>
{pageList.map((n, i) => (
n === '...' ? (
<span key={`gap-${i}`} className="history-page-gap">…</span>
) : (
<button
key={n}
className={n === page ? 'history-page-btn--active' : ''}
onClick={() => setPage(n)}
disabled={n === page}
>
{n}
</button>
)
))}
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
›
</button>
</div>
)}
</div>
{previewData && (
<ReportPreviewModal
vehicleData={previewData}
onClose={() => setPreviewVehicle(null)}
/>
)}
</div>
);
}
@@ -1,110 +1,110 @@
.license-manager {
min-height: 100vh;
background-color: #f5f5f7;
flex: 1 0 auto; /* fill the rail layout's content column */
background-color: var(--surface-sunken);
}
.lm-body {
padding: 20px;
padding: var(--space-5);
max-width: 800px;
margin: 0 auto;
}
.lm-loading,
.lm-empty {
padding: 24px;
padding: var(--space-6);
text-align: center;
color: #999;
color: var(--text-muted);
font-style: italic;
}
.lm-toolbar {
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
.lm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
padding: var(--space-1-5) var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: #666;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
font-size: var(--fs-base);
}
.lm-back:hover {
border-color: #667eea;
color: #667eea;
border-color: var(--accent);
color: var(--accent);
}
.lm-ok,
.lm-error {
padding: 8px 12px;
padding: var(--space-2) var(--space-3);
border-radius: 6px;
font-size: 13px;
margin-bottom: 12px;
font-size: var(--fs-base);
margin-bottom: var(--space-3);
}
.lm-ok {
background-color: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
background-color: var(--success-soft);
border: 1px solid var(--success);
color: var(--success);
}
.lm-error {
background-color: #fee;
border: 1px solid #fcc;
color: #c33;
background-color: var(--danger-soft);
border: 1px solid var(--danger);
color: var(--danger);
}
.lm-panel {
background: white;
background: var(--surface);
border-radius: 8px;
padding: 18px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
padding: var(--space-5);
margin-bottom: var(--space-4);
box-shadow: var(--shadow-card);
}
.lm-panel h3 {
margin: 0 0 12px 0;
font-size: 12px;
font-weight: 600;
color: #888;
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: 3px 10px;
padding: var(--space-1) var(--space-2-5);
border-radius: 4px;
font-size: 11px;
font-weight: 600;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
margin-bottom: var(--space-2-5);
}
.lm-badge--ok {
background-color: #e8f5e9;
color: #2e7d32;
background-color: var(--success-soft);
color: var(--success);
}
.lm-badge--warn {
background-color: #fff3cd;
color: #856404;
background-color: var(--warning-soft);
color: var(--warning);
}
.lm-facts {
display: grid;
grid-template-columns: 140px 1fr;
gap: 6px 12px;
gap: var(--space-1-5) var(--space-3);
margin: 0;
}
.lm-facts dt {
font-size: 11px;
font-weight: 600;
color: #888;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: center;
@@ -112,107 +112,107 @@
.lm-facts dd {
margin: 0;
font-size: 14px;
color: #333;
font-size: var(--fs-md);
color: var(--text);
}
.lm-reason {
margin: 0 0 8px 0;
font-size: 14px;
color: #333;
margin: 0 0 var(--space-2) 0;
font-size: var(--fs-md);
color: var(--text);
}
.lm-grace {
margin: 0;
font-size: 13px;
color: #666;
font-size: var(--fs-base);
color: var(--text-muted);
line-height: 1.5;
}
.lm-hint {
margin: 0 0 12px 0;
font-size: 13px;
color: #666;
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: 8px;
gap: var(--space-2);
}
.lm-fingerprint code {
flex: 1;
padding: 10px 12px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: var(--space-2-5) var(--space-3);
background-color: var(--surface-sunken);
border: 1px solid var(--border);
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 18px;
font-family: var(--font-mono);
font-size: var(--fs-xl);
letter-spacing: 1px;
color: #333;
color: var(--text);
word-break: break-all;
}
.lm-components {
font-size: 11px !important;
font-size: var(--fs-xs) !important;
letter-spacing: 0 !important;
}
.lm-fingerprint button {
padding: 8px 14px;
padding: var(--space-2) var(--space-4);
background: none;
border: 1px solid #ddd;
border: 1px solid var(--border);
border-radius: 6px;
color: #666;
font-size: 13px;
color: var(--text-muted);
font-size: var(--fs-base);
cursor: pointer;
flex-shrink: 0;
}
.lm-fingerprint button:hover {
border-color: #667eea;
color: #667eea;
border-color: var(--accent);
color: var(--accent);
}
.lm-details {
margin-top: 10px;
margin-top: var(--space-2-5);
}
.lm-details summary {
font-size: 12px;
color: #888;
font-size: var(--fs-sm);
color: var(--text-muted);
cursor: pointer;
margin-bottom: 8px;
margin-bottom: var(--space-2);
}
.lm-blob {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
padding: var(--space-2-5);
border: 1px solid var(--border);
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--fs-sm);
box-sizing: border-box;
resize: vertical;
}
.lm-install {
margin-top: 10px;
padding: 9px 18px;
background: linear-gradient(135deg, #667eea, #764ba2);
margin-top: var(--space-2-5);
padding: var(--space-2-5) var(--space-5);
background: var(--accent-gradient);
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
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 rgba(102, 126, 234, 0.4);
box-shadow: 0 3px 8px var(--accent-soft);
}
.lm-install:disabled {
@@ -226,41 +226,21 @@
}
.lm-table th {
padding: 0.5rem 0.75rem;
padding: var(--space-2) var(--space-3);
text-align: left;
font-weight: 600;
color: #495057;
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
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: #f8f9fa;
background-color: var(--surface-sunken);
}
.lm-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
color: #333;
font-family: 'Courier New', monospace;
}
/* Persistent, non-dismissible banner shown app-wide during grace */
.license-banner {
padding: 8px 16px;
background-color: #fff3cd;
border-bottom: 1px solid #ffe69c;
color: #856404;
font-size: 13px;
text-align: center;
}
.license-banner strong {
font-weight: 600;
}
.license-banner--blocked {
background-color: #fee;
border-bottom-color: #fcc;
color: #c33;
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);
}
@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import { useLicense } from '../../contexts/LicenseContext';
import api from '../../services/api';
import './LicenseManager.css';
@@ -40,7 +39,6 @@ export default function LicenseManager() {
if (!status) {
return (
<div className="license-manager">
<Header />
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
</div>
);
@@ -50,7 +48,6 @@ export default function LicenseManager() {
return (
<div className="license-manager">
<Header />
<div className="lm-body">
<div className="lm-toolbar">
<button className="lm-back" onClick={() => navigate('/')}>&larr; Back</button>
File diff suppressed because it is too large Load Diff
+359 -283
View File
@@ -1,7 +1,6 @@
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';
@@ -11,29 +10,32 @@ 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, status: licenseStatus } = useLicense();
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 [showDesignMenu, setShowDesignMenu] = useState(false);
const [isOneWeighing, setIsOneWeighing] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
@@ -99,6 +101,46 @@ export default function Main() {
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
@@ -124,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(() => {
@@ -144,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('');
};
@@ -152,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('');
};
@@ -168,6 +258,7 @@ 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 visibleNewExtra = prunedExtra(newExtraData, visibleForNew);
@@ -178,7 +269,7 @@ export default function Main() {
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;
@@ -203,6 +294,24 @@ export default function Main() {
}
};
// 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}/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;
@@ -277,61 +386,24 @@ 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);
const canMeasure = !!currentUser?.can_measure;
const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
// 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;
// The single recorded weighing, while its tare/gross role is still undecided.
// The backend parks a lone reading in tare, but a lighter second reading can move it to gross.
const firstWeighing = useMemo(() => {
if (!selectedVehicle || isFinalized) return null;
if (selectedVehicle.tare != null) {
return {
value: selectedVehicle.tare,
date: selectedVehicle.tare_date,
userName: selectedVehicle.tare_user_name,
manual: selectedVehicle.tare_manual,
};
}
if (selectedVehicle.gross != null) {
return {
value: selectedVehicle.gross,
date: selectedVehicle.gross_date,
userName: selectedVehicle.gross_user_name,
manual: selectedVehicle.gross_manual,
};
}
return null;
}, [selectedVehicle, isFinalized]);
// 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
// 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 ?? '';
}
}
return {
vehicle: {
...selectedVehicle,
net: netWeight,
extra,
}
};
}, [selectedVehicle, netWeight, definitions, entries]);
// 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
@@ -351,9 +423,12 @@ export default function Main() {
import('./ReportEditor/a4/print/printReport').catch(() => {});
};
// Print Notice is kind-aware: an A4 report goes straight to the browser
// print dialog (where "Save as PDF" is a destination), a matrix report opens
// the existing on-screen modal exactly as before.
// 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);
@@ -363,14 +438,7 @@ export default function Main() {
setError('Report "notice_report" not found. Create it in the Report Editor first.');
return;
}
if (report.kind === 'a4') {
// Lazy so Main.jsx does not pull the A4 print tree into the
// main bundle for operators who never print an A4 notice.
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
await printA4Report(report, vehicleReportData);
} else {
setShowNoticeReport(true);
}
setShowNoticeReport(true);
} catch (err) {
console.error('Failed to print notice:', err);
setError('Failed to load the notice report');
@@ -379,67 +447,8 @@ export default function Main() {
}
};
// Design Notice routes to the editor that can actually edit the saved
// report. A matrix report opened in the mm editor would read its character
// coordinates as millimetres, so the kind decides the route, not a default.
const handleDesignNotice = async () => {
const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
try {
const report = await fetchNoticeReport();
navigate(report?.kind === 'a4' ? '/report-editor-a4' : '/report-editor', { state });
} catch (err) {
// Nothing saved yet, or the lookup failed — the matrix editor is the
// existing default and pre-fills the name so Save creates it.
console.error('Failed to resolve notice report kind:', err);
navigate('/report-editor', { state });
}
};
/**
* Open a chosen editor regardless of what is saved.
*
* The automatic route above is right for editing an existing notice; this is
* for deliberately starting the OTHER kind. Opening a report in the wrong
* editor reinterprets its coordinates — character cells as millimetres, or
* the reverse — so a mismatch is confirmed rather than done silently.
*/
const handleDesignAs = async (kind) => {
setShowDesignMenu(false);
const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor';
let saved = null;
try {
saved = await fetchNoticeReport();
} catch (err) {
console.error('Failed to resolve notice report kind:', err);
}
if (saved && saved.kind !== kind) {
const savedLabel = saved.kind === 'a4' ? 'A4' : 'matrix';
const targetLabel = kind === 'a4' ? 'A4' : 'matrix';
const ok = window.confirm(
`"notice_report" is saved as a ${savedLabel} report.\n\n` +
`Opening it in the ${targetLabel} editor will read its positions in the ` +
`wrong units, and saving would overwrite the ${savedLabel} layout.\n\n` +
`Open the ${targetLabel} editor anyway?`
);
if (!ok) return;
}
navigate(route, { state });
};
return (
<div>
<Header />
{licenseStatus && !licenseStatus.valid && (
<div className={`license-banner ${licenseStatus.in_grace ? '' : 'license-banner--blocked'}`}>
{licenseStatus.in_grace
? <>This installation is not licensed. Everything keeps working for <strong>{licenseStatus.grace_days_left} more days</strong>.</>
: <>This installation is not licensed. Only {licenseStatus.stations} station can measure.</>}
</div>
)}
<div className="main-page">
<div className="main">
{/* Left panel - Vehicle list */}
<div className="main-left">
@@ -459,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>
@@ -487,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
@@ -525,16 +560,25 @@ 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">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombinationNew}
/>
<h3>Additional Data</h3>
<div className="extra-fields">
{visibleForNew.map(nom => (
<div key={nom.code} className="extra-field">
@@ -561,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>
@@ -575,112 +619,190 @@ 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)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{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>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
{/* 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">
{/* Before both weights exist, the first reading's role is not
final yet - show it neutrally so it doesn't appear to jump. */}
{selectedVehicle.tare == null || selectedVehicle.gross == null ? (
<div className="weight-card">
<div className="weight-card-header">First weighing</div>
<div className="weight-card-value">
{firstWeighing ? `${firstWeighing.value} kg` : '---'}
{/* 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>
{firstWeighing && (
<div className="weight-card-meta">
{formatDate(firstWeighing.date)}
{firstWeighing.userName && ` (${firstWeighing.userName})`}
{firstWeighing.manual && <span className="weight-manual-tag">manual</span>}
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
</div>
)}
</div>
) : (
<>
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">{selectedVehicle.tare} kg</div>
{/* Measure/Manual entry, moved here from the action column:
they act on THIS card's live value, so they sit beside it. */}
{canMeasure && !isFinalized && (
<div className="weight-card-reading-actions">
<button
className="weight-set-btn"
onClick={handleMeasure}
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
title={licenseAllowed ? undefined : licenseDetail}
>
Measure
</button>
{!licenseAllowed && (
<div className="weight-license-block">{licenseDetail}</div>
)}
{currentUser?.can_manually_measure && (
<button
className="weight-manual-btn"
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
>
Manual entry
</button>
)}
</div>
)}
</div>
{/* 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>
</div>
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
{/* Gross */}
<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>
{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>
</>
)}
{/* Measure */}
{canMeasure && !isFinalized && (
<div className="weight-card">
<div className="weight-card-header">Scale</div>
<div className={`weight-card-value ${
currentWeight === null || isNaN(currentWeight)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{currentWeight !== null && !isNaN(currentWeight) ? `${currentWeight} kg` : '---'}
</div>
<button
className="weight-set-btn"
onClick={handleMeasure}
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
title={licenseAllowed ? undefined : licenseDetail}
>
Measure
</button>
{!licenseAllowed && (
<div className="weight-license-block">{licenseDetail}</div>
)}
{currentUser?.can_manually_measure && (
<button
className="weight-manual-btn"
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
>
Manual entry
</button>
)}
</div>
)}
</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>
<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}
@@ -690,64 +812,18 @@ export default function Main() {
>
{isPrinting ? 'Preparing...' : 'Print Notice'}
</button>
{/* Split button: the main action routes by
the saved report's kind; the caret opens
an explicit choice for starting the other
kind. */}
<div className="design-notice-split">
<button
className="weight-set-btn design-notice-main"
onClick={handleDesignNotice}
>
Design Notice
</button>
<button
className="weight-set-btn design-notice-caret"
onClick={() => setShowDesignMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showDesignMenu}
title="Choose which editor to open"
>
▾
</button>
{showDesignMenu && (
<>
{/* Click-away closes the menu. */}
<div
className="design-notice-backdrop"
onClick={() => setShowDesignMenu(false)}
/>
<div className="design-notice-menu" role="menu">
<button
role="menuitem"
onClick={() => handleDesignAs('matrix')}
>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button
role="menuitem"
onClick={() => handleDesignAs('a4')}
>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
</div>
)}
)}
</div>
</div>
{/* Extra fields from nomenclatures */}
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<h3>Additional Data</h3>
<div className="extra-fields">
{visibleForSelected.map(nom => (
<div key={nom.code} className="extra-field">
+428
View File
@@ -0,0 +1,428 @@
/* App shell: the page content takes everything left of the rail and exactly
the viewport height. The content column scrolls on its own, so no screen can
produce a page (body) scrollbar; a screen that must fit without scrolling
(Main) sizes itself to this column with flex instead of calc(100vh - x). */
.app-shell {
display: flex;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.app-shell-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
/* The rail is the old header turned on its side, so it keeps the header's
surface tokens. */
.nav-rail {
--rail-width: 68px;
position: relative;
z-index: 100;
width: var(--rail-width);
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) 0;
background: var(--header-bg);
color: var(--header-text);
box-shadow: var(--shadow-raised);
}
.nav-rail-top,
.nav-rail-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.nav-rail-bottom {
gap: var(--space-2-5);
}
/* Positioning context for a flyout opening beside its trigger. */
.nav-rail-anchor {
position: relative;
display: flex;
}
.nav-rail-item {
width: 60px;
padding: var(--space-1-5) var(--space-0-5) var(--space-1);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-0-5);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--header-text);
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.nav-rail-item:hover {
background: var(--header-chrome);
border-color: var(--header-chrome-border);
}
.nav-rail-icon {
font-size: var(--fs-xl);
line-height: 1.2;
}
.nav-rail-label {
max-width: 100%;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The lit edge faces the content, the way a tab points at its page. */
.nav-rail-item--active,
.nav-rail-item--active:hover {
background: var(--header-chrome-hover);
border-color: var(--header-chrome-border);
box-shadow: inset 3px 0 0 var(--header-active);
}
.nav-rail-item:focus-visible,
.nav-rail-theme:focus-visible,
.nav-rail-avatar:focus-visible {
outline: 2px solid var(--header-active);
outline-offset: 2px;
}
.nav-rail-theme {
width: 38px;
height: 38px;
border-radius: var(--radius-sm);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
font-size: var(--fs-xl);
line-height: 1;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-rail-theme:hover {
background: var(--header-chrome-hover);
transform: scale(1.05);
}
.nav-rail-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
padding: 0;
background: var(--header-avatar-bg);
color: var(--header-avatar-text);
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
font-weight: var(--fw-bold);
font-size: var(--fs-lg);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-card);
}
.nav-rail-avatar:hover {
transform: scale(1.05);
box-shadow: var(--shadow-raised);
}
/* Over the page, under the flyout: any outside click closes it. */
.nav-rail-backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
/* Flyouts open to the LEFT of the rail, over the page content. */
.nav-rail-flyout {
position: absolute;
right: calc(100% + var(--space-2-5));
z-index: 2;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
.nav-rail-menu {
top: 0;
min-width: 220px;
}
/* The avatar sits at the foot of the rail, so its menu grows upwards. */
.nav-rail-user-menu {
bottom: 0;
min-width: 180px;
}
.nav-rail-flyout button {
display: block;
width: 100%;
padding: var(--space-2-5) var(--space-4);
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: inherit;
font-size: var(--fs-md);
}
.nav-rail-flyout button + button,
.nav-rail-user + button {
border-top: 1px solid var(--border);
}
.nav-rail-flyout button:hover,
.nav-rail-flyout button:focus-visible {
background: var(--surface-hover);
outline: none;
}
.nav-rail-menu strong,
.nav-rail-user strong {
display: block;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
}
.nav-rail-menu span,
.nav-rail-user span {
display: block;
margin-top: var(--space-0-5);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.nav-rail-user {
padding: var(--space-2-5) var(--space-4);
color: var(--text);
background: var(--surface-sunken);
}
/* Licence warning: amber while in grace, red once measuring is blocked. The
colour drives the label, the "!" marker and the badge; an emoji icon keeps
its own colours, so the item also gets a tinted surface. */
.nav-rail-item--license-grace {
--license-tone: var(--warning);
--license-tone-soft: var(--warning-soft);
}
.nav-rail-item--license-blocked {
--license-tone: var(--danger);
--license-tone-soft: var(--danger-soft);
}
.nav-rail-item--license {
color: var(--license-tone);
background: var(--license-tone-soft);
}
.nav-rail-item--license:hover {
background: var(--license-tone-soft);
border-color: var(--license-tone);
}
.nav-rail-item--license .nav-rail-icon {
position: relative;
font-weight: var(--fw-bold);
}
.nav-rail-badge {
position: absolute;
top: 0;
right: -6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--license-tone);
box-shadow: 0 0 0 2px var(--header-bg);
}
/* One gentle pulse when the badge first appears, never on a loop. */
@media (prefers-reduced-motion: no-preference) {
.nav-rail-badge {
animation: nav-rail-badge-pulse 1.2s ease-out 1;
}
}
@keyframes nav-rail-badge-pulse {
0% { transform: scale(1); }
35% { transform: scale(1.6); }
100% { transform: scale(1); }
}
.nav-rail-license-popover {
top: 0;
width: 260px;
border-left: 3px solid var(--warning);
}
.nav-rail-license-popover--blocked {
border-left-color: var(--danger);
}
/* The non-admin marker sits near the foot of the rail: grow upwards. */
.nav-rail-license-popover--bottom {
top: auto;
bottom: 0;
}
.nav-rail-license-popover p {
margin: 0;
padding: var(--space-2-5) var(--space-4);
color: var(--text);
font-size: var(--fs-md);
line-height: 1.4;
}
.nav-rail-license-popover strong {
font-weight: var(--fw-semibold);
}
.nav-rail-license-popover button {
border-top: 1px solid var(--border);
font-weight: var(--fw-semibold);
}
/* Cockpit: the rail needs its own lit edge facing the content, or it floats,
and the active item glows like the rest of the lit instruments. */
[data-theme="cockpit"] .nav-rail {
border-left: 1px solid var(--border);
box-shadow: -1px 0 0 color-mix(in srgb, var(--accent) 25%, transparent),
var(--shadow-raised);
}
[data-theme="cockpit"] .nav-rail-item--active {
color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent), var(--glow-accent);
}
[data-theme="cockpit"] .nav-rail-flyout {
border-color: var(--border-strong);
}
[data-theme="cockpit"] .nav-rail-license-popover {
border-left-color: var(--warning);
}
[data-theme="cockpit"] .nav-rail-license-popover--blocked {
border-left-color: var(--danger);
}
[data-theme="cockpit"] .nav-rail-item--license-grace .nav-rail-badge {
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-warning);
}
[data-theme="cockpit"] .nav-rail-item--license-blocked .nav-rail-badge {
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-danger);
}
/* Short windows: drop the labels before the rail runs out of height. */
@media (max-height: 620px) and (min-width: 700px) {
.nav-rail-label {
display: none;
}
.nav-rail-item {
padding: var(--space-1-5) var(--space-0-5);
}
}
/* Narrow screens: the rail becomes a bottom bar, icons only. */
@media (max-width: 699px) {
.app-shell {
flex-direction: column;
}
.nav-rail {
width: auto;
flex-direction: row;
align-items: center;
padding: var(--space-1) var(--space-2);
}
[data-theme="cockpit"] .nav-rail {
border-left: none;
border-top: 1px solid var(--border);
}
.nav-rail-top,
.nav-rail-bottom {
flex-direction: row;
gap: var(--space-0-5);
}
.nav-rail-top {
flex: 1;
min-width: 0;
justify-content: space-around;
}
.nav-rail-item {
width: auto;
min-width: 36px;
padding: var(--space-1);
}
.nav-rail-label {
display: none;
}
.nav-rail-item--active,
.nav-rail-item--active:hover,
[data-theme="cockpit"] .nav-rail-item--active {
box-shadow: inset 0 -3px 0 var(--header-active);
}
.nav-rail-theme,
.nav-rail-avatar {
width: 34px;
height: 34px;
font-size: var(--fs-md);
}
/* Above the bar instead of beside it. */
.nav-rail-flyout {
right: auto;
left: 0;
top: auto;
bottom: calc(100% + var(--space-2));
}
.nav-rail-user-menu,
.nav-rail-license-popover {
left: auto;
right: 0;
}
.nav-rail-license-popover {
max-width: calc(100vw - 2 * var(--space-4));
}
}
+355
View File
@@ -0,0 +1,355 @@
import React, { useState, useEffect, useRef, useId } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useLicense } from '../contexts/LicenseContext';
import { getLicenseWarning } from './licenseWarning';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './NavRail.css';
function RailItem({ icon, label, title, onClick, isActive = false, buttonRef, className = '', noTooltip = false, ...rest }) {
return (
<button
ref={buttonRef}
className={`nav-rail-item${isActive ? ' nav-rail-item--active' : ''}${className ? ` ${className}` : ''}`}
onClick={onClick}
title={noTooltip ? undefined : title}
aria-label={title}
aria-current={isActive ? 'page' : undefined}
{...rest}
>
<span className="nav-rail-icon" aria-hidden="true">{icon}</span>
<span className="nav-rail-label" aria-hidden="true">{label}</span>
</button>
);
}
const LICENSE_OPEN_DELAY_MS = 200;
const LICENSE_CLOSE_DELAY_MS = 250;
// Licence item in its warning state, with a popover beside the rail that opens
// on hover (after a short delay), focus or click. Admins get the real Licence
// item, which still navigates; everyone else gets a "!" marker that does not.
function LicenseWarning({ warning, isAdmin, isActive, onOpenPage }) {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef(null);
const triggerRef = useRef(null);
const timerRef = useRef(null);
const skipFocusOpenRef = useRef(false);
const popoverId = useId();
useEffect(() => () => clearTimeout(timerRef.current), []);
useEffect(() => {
if (!isOpen) return undefined;
const handleMouseDown = (e) => {
if (!anchorRef.current?.contains(e.target)) setIsOpen(false);
};
const handleKeyDown = (e) => {
if (e.key !== 'Escape') return;
clearTimeout(timerRef.current);
setIsOpen(false);
// Only pull focus back when it was inside the popover itself.
const active = document.activeElement;
if (active !== triggerRef.current && anchorRef.current?.contains(active)) {
skipFocusOpenRef.current = true;
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const openNow = () => {
clearTimeout(timerRef.current);
setIsOpen(true);
};
// Entering again (e.g. trigger -> popover across the gap) cancels a pending close.
const handleMouseEnter = () => {
clearTimeout(timerRef.current);
if (!isOpen) timerRef.current = setTimeout(() => setIsOpen(true), LICENSE_OPEN_DELAY_MS);
};
const handleMouseLeave = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIsOpen(false), LICENSE_CLOSE_DELAY_MS);
};
const handleFocus = () => {
if (skipFocusOpenRef.current) {
skipFocusOpenRef.current = false;
return;
}
openNow();
};
// A null relatedTarget is a click on something unfocusable; the outside
// mousedown handler covers the case where that was outside the anchor.
const handleBlur = (e) => {
if (e.relatedTarget && !anchorRef.current?.contains(e.relatedTarget)) {
clearTimeout(timerRef.current);
setIsOpen(false);
}
};
const handleOpenPage = () => {
setIsOpen(false);
onOpenPage();
};
const badgeIcon = (icon) => (
<>
{icon}
<span className="nav-rail-badge" />
</>
);
return (
<div
ref={anchorRef}
className="nav-rail-anchor"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
>
<RailItem
buttonRef={triggerRef}
className={`nav-rail-item--license nav-rail-item--license-${warning.level}`}
icon={badgeIcon(isAdmin ? '🔑' : '!')}
label="Licence"
title={`Licence: ${warning.message}`}
noTooltip /* the popover already shows the message; aria-label keeps it for screen readers */
onClick={isAdmin ? onOpenPage : openNow}
onFocus={handleFocus}
isActive={isActive}
aria-describedby={isOpen ? popoverId : undefined}
aria-expanded={isOpen}
aria-controls={isOpen ? popoverId : undefined}
/>
{isOpen && (
<div
id={popoverId}
className={`nav-rail-flyout nav-rail-license-popover nav-rail-license-popover--${warning.level}${isAdmin ? '' : ' nav-rail-license-popover--bottom'}`}
role={isAdmin ? 'dialog' : 'tooltip'}
aria-label={isAdmin ? 'Licence warning' : undefined}
>
<p>
{warning.lead}
{warning.emphasis && <strong>{warning.emphasis}</strong>}
{warning.tail}
</p>
{isAdmin && (
<button onClick={handleOpenPage}>Open licence page</button>
)}
</div>
)}
</div>
);
}
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return (user.username || '').substring(0, 2).toUpperCase();
};
function NavRail() {
const { currentUser, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { status: licenseStatus } = useLicense();
const licenseWarning = getLicenseWarning(licenseStatus);
const navigate = useNavigate();
const { pathname } = useLocation();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const [showEditorMenu, setShowEditorMenu] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const editorButtonRef = useRef(null);
const avatarButtonRef = useRef(null);
// Escape closes whichever flyout is open and hands focus back to its trigger.
useEffect(() => {
if (!showEditorMenu && !showUserMenu) return undefined;
const handleKeyDown = (e) => {
if (e.key !== 'Escape') return;
const trigger = showEditorMenu ? editorButtonRef.current : avatarButtonRef.current;
setShowEditorMenu(false);
setShowUserMenu(false);
trigger?.focus();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showEditorMenu, showUserMenu]);
const openEditor = (route) => {
setShowEditorMenu(false);
navigate(route);
};
const handleChangePassword = () => {
setShowUserMenu(false);
setShowPasswordOverlay(true);
};
const handleLogout = () => {
setShowUserMenu(false);
logout();
};
return (
<>
<nav className="nav-rail" aria-label="Main navigation">
<div className="nav-rail-top">
<RailItem
icon="⚖️"
label="Weighing"
title="Weighing"
onClick={() => navigate('/')}
isActive={pathname === '/'}
/>
{currentUser?.can_edit_documents && (
<div className="nav-rail-anchor">
<RailItem
buttonRef={editorButtonRef}
icon="📝"
label="Reports"
title="Report Editor"
onClick={() => setShowEditorMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showEditorMenu}
/>
{showEditorMenu && (
<>
{/* Click-away close, without a document-level listener. */}
<div
className="nav-rail-backdrop"
onClick={() => setShowEditorMenu(false)}
/>
<div className="nav-rail-flyout nav-rail-menu" role="menu">
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
)}
{currentUser?.can_manage_entities && (
<RailItem
icon="📋"
label="Lists"
title="Nomenclatures"
onClick={() => setShowNomenclatureManager(true)}
/>
)}
<RailItem
icon="🕘"
label="History"
title="History"
onClick={() => navigate('/history')}
isActive={pathname === '/history'}
/>
{currentUser?.is_admin && (
<>
<RailItem
icon="👥"
label="Users"
title="User Manager"
onClick={() => navigate('/users')}
isActive={pathname === '/users'}
/>
<RailItem
icon="📡"
label="Scale"
title="Scale Monitor"
onClick={() => navigate('/scale-monitor')}
isActive={pathname === '/scale-monitor'}
/>
{licenseWarning ? (
<LicenseWarning
warning={licenseWarning}
isAdmin
isActive={pathname === '/license'}
onOpenPage={() => navigate('/license')}
/>
) : (
<RailItem
icon="🔑"
label="Licence"
title="Licence"
onClick={() => navigate('/license')}
isActive={pathname === '/license'}
/>
)}
</>
)}
</div>
<div className="nav-rail-bottom">
{licenseWarning && !currentUser?.is_admin && (
<LicenseWarning warning={licenseWarning} isAdmin={false} />
)}
<button
className="nav-rail-theme"
onClick={toggleTheme}
title={theme === 'cockpit' ? 'Switch to the classic theme' : 'Switch to the cockpit theme'}
aria-label="Toggle colour theme"
>
{theme === 'cockpit' ? '☀' : '◐'}
</button>
<div className="nav-rail-anchor">
<button
ref={avatarButtonRef}
className="nav-rail-avatar"
onClick={() => setShowUserMenu(v => !v)}
title={currentUser?.username}
aria-label={`Account: ${currentUser?.username ?? ''}`}
aria-haspopup="menu"
aria-expanded={showUserMenu}
>
{getInitials(currentUser || {})}
</button>
{showUserMenu && (
<>
<div
className="nav-rail-backdrop"
onClick={() => setShowUserMenu(false)}
/>
<div className="nav-rail-flyout nav-rail-user-menu" role="menu">
<div className="nav-rail-user">
<strong>{currentUser?.username}</strong>
<span>{currentUser?.role}</span>
</div>
<button role="menuitem" onClick={handleChangePassword}>Change password</button>
<button role="menuitem" onClick={handleLogout}>Logout</button>
</div>
</>
)}
</div>
</div>
</nav>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
export default NavRail;
@@ -5,12 +5,12 @@
left: 0;
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,20 +117,20 @@
.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 {
@@ -139,7 +139,7 @@
/* Inset shadow rather than a border, so the drop line adds no row height */
.nm-list-item--drag-over {
box-shadow: inset 0 2px 0 #667eea;
box-shadow: inset 0 2px 0 var(--accent);
}
.nm-list-item-info {
@@ -149,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;
@@ -159,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 */
@@ -207,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;
}
@@ -259,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 {
@@ -323,7 +323,7 @@
/* Fields table */
.nm-fields-table {
border: 1px solid #e0e0e0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
@@ -331,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;
}
@@ -344,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"] {
@@ -388,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;
@@ -402,8 +402,8 @@
}
.nm-remove-btn:hover:not(:disabled) {
color: #c33;
background: #fee;
color: var(--danger);
background: var(--danger-soft);
}
.nm-remove-btn:disabled {
@@ -415,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;
}
@@ -467,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 */
@@ -529,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,
@@ -581,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 {
@@ -617,7 +617,7 @@
width: 100%;
max-height: 200px;
border-right: none;
border-bottom: 1px solid #e0e0e0;
border-bottom: 1px solid var(--border);
}
.nm-form-grid {
@@ -628,50 +628,50 @@
/* --- Selector options editor --- */
.nm-options-empty {
margin: 0;
padding: 10px 8px;
font-size: 13px;
color: #888;
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 #f0f0f0;
padding: 8px;
border-top: 1px solid var(--border);
padding: var(--space-2);
}
.nm-option-head {
display: grid;
grid-template-columns: 1fr 1fr 32px;
gap: 6px;
gap: var(--space-1-5);
align-items: center;
}
.nm-option-head 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-option-head input[type="text"]:focus {
outline: none;
border-color: #667eea;
border-color: var(--accent);
}
.nm-option-remove {
background: none;
border: none;
color: #c0392b;
font-size: 18px;
color: var(--danger);
font-size: var(--fs-xl);
cursor: pointer;
padding: 0;
line-height: 1;
}
.nm-option-remove:disabled {
color: #ccc;
color: var(--border-strong);
cursor: not-allowed;
}
@@ -679,14 +679,14 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 6px;
padding-left: 2px;
gap: var(--space-2-5);
margin-top: var(--space-1-5);
padding-left: var(--space-0-5);
}
.nm-option-reveals-label {
font-size: 12px;
color: #888;
font-size: var(--fs-sm);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -694,8 +694,8 @@
.nm-option-reveal {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #333;
gap: var(--space-1);
font-size: var(--fs-base);
color: var(--text);
cursor: pointer;
}
@@ -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 {
@@ -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;
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import NavRail from './NavRail';
// Shell for every screen that carries the navigation rail. Mounted once as a
// layout route, so the rail does not remount (and lose an open menu) when the
// user moves between the pages inside it.
function RailLayout() {
return (
<div className="app-shell">
<main className="app-shell-content">
<Outlet />
</main>
<NavRail />
</div>
);
}
export default RailLayout;
@@ -4,9 +4,9 @@
top: 0;
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;
@@ -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);
}
@@ -3,8 +3,8 @@
.a4-canvas-container {
flex: 1;
overflow: auto;
background: #8a8a8a;
padding: 24px;
background: var(--desk);
padding: var(--space-6);
display: flex;
justify-content: center;
align-items: flex-start;
@@ -13,6 +13,7 @@
.a4-page {
position: relative;
background: #fff;
color: #000;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
flex: none;
}
@@ -102,8 +103,8 @@
/* Rubber-band selection rectangle */
.a4-selection-rectangle {
position: absolute;
border: 1px dashed #667eea;
background: rgba(102, 126, 234, 0.12);
border: 1px dashed var(--accent);
background: var(--accent-soft);
pointer-events: none;
z-index: 15;
}
@@ -126,13 +127,13 @@
.a4-preview-page-break {
width: 100%;
padding: 6px 0 14px;
padding: var(--space-1-5) 0 var(--space-4);
text-align: center;
font-family: system-ui, sans-serif;
font-size: 11px;
color: #e8e8e8;
border-top: 1px dashed rgba(255, 255, 255, 0.4);
margin-top: 14px;
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 {
@@ -2,36 +2,36 @@
.a4-inspector .mm-suffix {
flex: 0 0 auto;
margin-left: 6px;
color: #999;
font-size: 11px;
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: 4px;
gap: var(--space-1);
}
.a4-inspector .a4-style-btn {
flex: 1;
padding: 4px 0;
border: 1px solid #ccc;
padding: var(--space-1) 0;
border: 1px solid var(--border-strong);
border-radius: 3px;
background: #fff;
background: var(--surface);
cursor: pointer;
font-size: 12px;
font-size: var(--fs-sm);
}
.a4-inspector .a4-style-btn:hover {
background: #e8eeff;
border-color: #667eea;
background: var(--accent-soft);
border-color: var(--accent);
}
.a4-inspector .a4-style-btn.active {
background: #667eea;
border-color: #667eea;
color: #fff;
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.a4-inspector input[type="color"] {
@@ -42,16 +42,16 @@
.a4-inspector .a4-expression-input {
width: 100%;
box-sizing: border-box;
margin-bottom: 4px;
font-family: monospace;
font-size: 11px;
margin-bottom: var(--space-1);
font-family: var(--font-mono);
font-size: var(--fs-xs);
resize: vertical;
}
.a4-inspector .inspector-hint {
padding: 2px 0 6px;
color: #999;
font-size: 10px;
padding: var(--space-0-5) 0 var(--space-1-5);
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.3;
}
@@ -3,7 +3,7 @@
.a4-kind-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -11,44 +11,44 @@
}
.a4-kind-dialog {
background: #fff;
background: var(--surface);
border-radius: 8px;
width: 460px;
max-width: 92vw;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-overlay);
}
.a4-kind-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e0e0e0;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.a4-kind-header h2 {
margin: 0;
font-size: 16px;
color: #333;
font-size: var(--fs-lg);
color: var(--text);
}
.a4-kind-close {
border: none;
background: none;
font-size: 22px;
font-size: var(--fs-2xl);
line-height: 1;
color: #888;
color: var(--text-muted);
cursor: pointer;
}
.a4-kind-close:hover {
color: #333;
color: var(--text);
}
.a4-kind-options {
display: flex;
gap: 12px;
padding: 18px;
gap: var(--space-3);
padding: var(--space-5);
}
.a4-kind-option {
@@ -56,45 +56,45 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 16px;
border: 1px solid #ddd;
gap: var(--space-1-5);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: 6px;
background: #fafafa;
background: var(--surface-sunken);
cursor: pointer;
text-align: left;
font: inherit;
}
.a4-kind-option:hover {
border-color: #667eea;
background: #f0f3ff;
border-color: var(--accent);
background: var(--accent-soft);
}
.a4-kind-option-icon {
font-size: 22px;
font-size: var(--fs-2xl);
}
.a4-kind-option-title {
font-size: 14px;
font-weight: 600;
color: #333;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--text);
}
.a4-kind-option-desc {
font-size: 11px;
font-size: var(--fs-xs);
line-height: 1.45;
color: #777;
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: 10px 4px 4px;
font-size: 11px;
padding: var(--space-2-5) var(--space-1) var(--space-1);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: #999;
color: var(--text-muted);
}
.load-dialog-item--other {
@@ -106,12 +106,12 @@
}
.load-dialog-kind-badge {
margin-left: 8px;
padding: 1px 6px;
margin-left: var(--space-2);
padding: 1px var(--space-1-5);
border-radius: 9px;
background: #e8e8e8;
color: #666;
font-size: 10px;
background: var(--surface-hover);
color: var(--text-muted);
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
vertical-align: middle;
@@ -4,7 +4,7 @@
.a4-page-setup-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -12,55 +12,55 @@
}
.a4-page-setup {
background: #fff;
background: var(--surface);
border-radius: 8px;
width: 340px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-overlay);
}
.a4-page-setup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e0e0e0;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.a4-page-setup-header h2 {
margin: 0;
font-size: 16px;
color: #333;
font-size: var(--fs-lg);
color: var(--text);
}
.a4-page-setup-close {
border: none;
background: none;
font-size: 22px;
font-size: var(--fs-2xl);
line-height: 1;
color: #888;
color: var(--text-muted);
cursor: pointer;
}
.a4-page-setup-close:hover {
color: #333;
color: var(--text);
}
.a4-page-setup-body {
padding: 8px 18px 18px;
padding: var(--space-2) var(--space-5) var(--space-5);
overflow-y: auto;
}
.a4-page-setup-dims,
.a4-page-setup-hint {
font-size: 11px;
color: #888;
margin-top: 6px;
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: var(--space-1-5);
line-height: 1.4;
}
.a4-page-setup-dims {
color: #667eea;
color: var(--accent);
}
@@ -6,16 +6,16 @@
}
.toolbar-zoom {
padding: 8px 10px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(255, 255, 255, 0.3);
padding: var(--space-2) var(--space-2-5);
background: var(--surface);
border: 1px solid var(--header-chrome-border);
border-radius: 6px;
font-size: 14px;
color: #333;
font-size: var(--fs-md);
color: var(--text);
cursor: pointer;
}
.toolbar-zoom:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
box-shadow: 0 0 0 2px var(--header-chrome);
}
@@ -83,14 +83,17 @@ const click = (el) =>
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
// ---------------------------------------------------------------------------
// Main.jsx's Print / Design Notice dispatch
// notice_report lookup + route-by-kind dispatch
// ---------------------------------------------------------------------------
/**
* The branch under test, mirroring Main.jsx's handlePrintNotice /
* handleDesignNotice. Kept as one function so a change to the real dispatch
* that is not made here shows up as an obviously stale test rather than a
* passing one.
* The branch under test: the notice_report lookup (Main.jsx's
* fetchNoticeReport, used by handlePrintNotice) and the route-by-kind rule
* the preview modal's design action (ReportPreviewModal's goDesign) applies.
* Main.jsx no longer has a Design Notice button; the editors stay reachable
* from the header and the preview modal. Kept as one function so a change to
* the real dispatch that is not made here shows up as an obviously stale test
* rather than a passing one.
*/
async function noticeDispatch({ onModal, onA4Print, onNavigate }) {
const res = await api.get('/api/reports/?name=notice_report');
@@ -106,7 +109,7 @@ async function noticeDispatch({ onModal, onA4Print, onNavigate }) {
return { found: true, kind: report.kind };
}
describe('Print / Design Notice dispatch', () => {
describe('notice_report dispatch', () => {
test('a matrix notice_report opens the existing modal, not the A4 print path', async () => {
api.get.mockResolvedValueOnce({
data: [{ id: 1, name: 'notice_report', kind: 'matrix', elements: [] }]
@@ -120,7 +123,7 @@ describe('Print / Design Notice dispatch', () => {
expect(out).toEqual({ found: true, kind: 'matrix' });
expect(onModal).toHaveBeenCalledTimes(1);
expect(onA4Print).not.toHaveBeenCalled();
// Design Notice must route to the MATRIX editor for a matrix report.
// Designing must route to the MATRIX editor for a matrix report.
expect(onNavigate).toHaveBeenCalledWith('/report-editor');
});
@@ -36,6 +36,7 @@
position: relative;
overflow: hidden;
background: #fff;
color: #000;
page-break-after: always;
break-after: page;
}
@@ -0,0 +1,271 @@
import React from 'react';
import { formatValue } from '../a4/formatValue';
import { resolveDBTextValue } from '../utils/dataResolver';
import { evaluateExpression } from '../utils/expressionEvaluator';
import { detectCrossroads } from '../utils/crossroadDetector';
import { calculateBandInstances } from '../utils/bandRenderer';
import './print.css';
/**
* Renders matrix (character-grid) report elements as plain positioned HTML.
*
* DELIBERATELY DUMB, same spirit as a4/print/PrintDocument.jsx: no hooks, no
* context, no state, no onSelect/onUpdate/onDelete/onDragStart. Everything
* EditorCanvas.jsx needs an interactive tree for — drag, selection, resize
* handles, click-to-place tools — has no meaning for a printed page, so none
* of it exists here.
*
* Band iteration and data resolution are NOT reimplemented: this reuses the
* exact same pure helpers EditorCanvas.jsx's own preview-mode branches call
* (calculateBandInstances from utils/bandRenderer.js, resolveDBTextValue from
* utils/dataResolver.js, evaluateExpression from utils/expressionEvaluator.js,
* detectCrossroads from utils/crossroadDetector.js) — none of those take a
* hook or a React context, they take plain data and return plain data, which
* is what let this component exist without borrowing EditorCanvas itself.
* formatValue is imported from a4/formatValue.js rather than re-duplicated a
* third time — it's already a verbatim, dependency-free copy of the same
* function inline in DBTextField.jsx/ExpressionField.jsx (see that file's own
* comment), and it does not import anything a4-specific.
*
* What IS re-derived here (not extracted, because it lives inline inside
* EditorCanvas.jsx's render tangled up with the interactive tree, and pulling
* it out would touch that 682-line file for matrix-print's sake alone): the
* "standalone elements shift down below expanded bands" calculation from
* EditorCanvas.jsx's preview-mode block. It is small (a Map keyed by band id)
* and reads only band instance output, not any interactive state.
*/
const CHAR_WIDTH = 18;
const CHAR_HEIGHT = 32;
const BORDER_CHARS = {
single: { topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘', horizontal: '─', vertical: '│' },
double: { topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', horizontal: '═', vertical: '║' }
};
const LINE_CHARS = {
hline: { single: '─', double: '═' },
vline: { single: '│', double: '║' }
};
function frameLines(element) {
const chars = BORDER_CHARS[element.borderStyle || 'single'];
const { width, height } = element;
const lines = [];
for (let row = 0; row < height; row++) {
let line = '';
for (let col = 0; col < width; col++) {
if (row === 0 && col === 0) line += chars.topLeft;
else if (row === 0 && col === width - 1) line += chars.topRight;
else if (row === height - 1 && col === 0) line += chars.bottomLeft;
else if (row === height - 1 && col === width - 1) line += chars.bottomRight;
else if (row === 0 || row === height - 1) line += chars.horizontal;
else if (col === 0 || col === width - 1) line += chars.vertical;
else line += ' ';
}
lines.push(line);
}
return lines;
}
function hlineText(element, crossroadMap) {
const lineChar = LINE_CHARS.hline[element.lineStyle || 'single'];
let line = '';
for (let i = 0; i < element.length; i++) {
const key = `${element.x + i},${element.y}`;
line += crossroadMap[key] || lineChar;
}
return line;
}
function vlineChars(element, crossroadMap) {
const lineChar = LINE_CHARS.vline[element.lineStyle || 'single'];
const chars = [];
for (let i = 0; i < element.length; i++) {
const key = `${element.x},${element.y + i}`;
chars.push(crossroadMap[key] || lineChar);
}
return chars;
}
/** Resolve a dbtext/expr element's display string, exactly as the on-screen
* DBTextField.jsx/ExpressionField.jsx do in previewMode=true. */
function resolveText(element, reportData, parentBandId, bandContext) {
if (element.type === 'dbtext') {
const raw = resolveDBTextValue(reportData, element.objectKey, element.fieldPath, parentBandId, bandContext);
return formatValue(raw, element.dataType || 'general');
}
if (element.type === 'expr') {
const resolveField = (ref) => {
const inner = ref.slice(1, ref.length - 1);
const dotIndex = inner.indexOf('.');
if (dotIndex === -1) {
return resolveDBTextValue(reportData, '', inner, parentBandId, bandContext);
}
const objectKey = inner.slice(0, dotIndex);
const fieldPath = inner.slice(dotIndex + 1);
return resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, bandContext);
};
const raw = evaluateExpression(element.expression, resolveField);
return formatValue(raw, element.dataType || 'general');
}
// type === 'text': static content, no data resolution.
return element.content;
}
function styleFor(element, x, y) {
return {
left: `${x * CHAR_WIDTH}px`,
top: `${y * CHAR_HEIGHT}px`,
width: `${(element.width || 1) * CHAR_WIDTH}px`,
height: `${(element.height || 1) * CHAR_HEIGHT}px`,
textAlign: element.alignment || 'left'
};
}
/** Render one element (standalone or band child) at its already-resolved
* grid position. `y` is absolute; `element.x`/`element.y` are used only for
* frame/line geometry that depends on the design-time footprint. */
function renderElement(element, x, y, reportData, parentBandId, bandContext, crossroadMap, key) {
switch (element.type) {
case 'text':
case 'dbtext':
case 'expr': {
const text = resolveText(element, reportData, parentBandId, bandContext);
return (
<div
key={key}
className={`matrix-print-el matrix-print-el--${element.type}`}
style={styleFor(element, x, y)}
>
{text}
</div>
);
}
case 'frame': {
const lines = frameLines(element);
return (
<div
key={key}
className="matrix-print-el matrix-print-el--frame"
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px` }}
>
{lines.map((line, i) => (
<div className="matrix-print-frame-line" key={i}>{line}</div>
))}
</div>
);
}
case 'hline': {
// Crossroad lookup uses DESIGN-time coordinates (element.x/y), since
// detectCrossroads() was built from the original elements array, not
// shifted band-instance positions. Standalone hlines have x===element.x
// and y===element.y anyway; band-child hlines don't participate in
// crossroad detection today (detectCrossroads runs once, pre-shift, in
// MatrixPrintDocument below — same limitation EditorCanvas.jsx has,
// since it too computes crossroadMap once from the raw elements list).
return (
<div
key={key}
className="matrix-print-el matrix-print-el--hline"
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px` }}
>
{hlineText(element, crossroadMap)}
</div>
);
}
case 'vline': {
const chars = vlineChars(element, crossroadMap);
return (
<div
key={key}
className="matrix-print-el matrix-print-el--vline"
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px` }}
>
{chars.map((char, i) => (
<div key={i} style={{ height: `${CHAR_HEIGHT}px` }}>{char}</div>
))}
</div>
);
}
case 'symbol':
return (
<div
key={key}
className="matrix-print-el matrix-print-el--symbol"
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px`, width: CHAR_WIDTH, height: CHAR_HEIGHT }}
>
{element.char}
</div>
);
default:
return null;
}
}
function MatrixPrintDocument({ elements, vehicleData }) {
const crossroadMap = detectCrossroads(elements);
const bands = elements.filter(el => el.type === 'band');
const bandInstances = calculateBandInstances(bands, vehicleData, elements);
// Same "how far did each top-level band expand past its design footprint"
// calculation EditorCanvas.jsx's preview-mode standalone-element branch
// does, so non-band elements shift down to make room for repeated rows.
const bandShifts = new Map();
bands.filter(band => !band.parentBandId).forEach(band => {
const bandDesignMaxY = band.y + band.height - 1;
const instances = bandInstances.filter(inst => inst.bandId === band.id);
if (instances.length > 0) {
const lastInstance = instances[instances.length - 1];
const bandPreviewMaxY = lastInstance.y + lastInstance.height - 1;
bandShifts.set(band.id, { designMaxY: bandDesignMaxY, expansion: bandPreviewMaxY - bandDesignMaxY });
}
});
const standaloneElements = elements.filter(el => {
if (el.type === 'band') return false;
const isChildOfBand = elements.some(band => band.type === 'band' && band.children?.includes(el.id));
return !isChildOfBand;
});
const renderedBandChildren = bandInstances.flatMap(instance => {
const bandData = instance.data ? { [instance.bandId]: instance.data } : {};
return instance.childElements.map(childEl => {
const absoluteY = instance.y + childEl.y;
return renderElement(
childEl,
childEl.x,
absoluteY,
vehicleData,
instance.bandId,
{ currentBandData: bandData },
crossroadMap,
`${instance.bandId}-${instance.instanceIndex}-${childEl.id}`
);
});
});
const renderedStandalone = standaloneElements.map(element => {
let totalShift = 0;
bandShifts.forEach(({ designMaxY, expansion }) => {
if (element.y > designMaxY) totalShift += expansion;
});
return renderElement(element, element.x, element.y + totalShift, vehicleData, null, {}, crossroadMap, element.id);
});
return (
<div className="print-root">
<div className="matrix-print-page">
{renderedBandChildren}
{renderedStandalone}
</div>
</div>
);
}
export default MatrixPrintDocument;
@@ -0,0 +1,60 @@
/**
* Print styles for the matrix (character-grid) designer's print/preview path.
*
* Mirrors a4/print/print.css in structure and in the reason it exists: a
* genuinely non-interactive renderer (MatrixPrintDocument) needs its own
* plain CSS, not the on-screen editor's elements.css classes, which carry
* cursor/hover/selected/dragging states that have no meaning once there is
* no interaction to style.
*
* Geometry is character-grid pixels (CHAR_WIDTH=18, CHAR_HEIGHT=32), NOT
* millimetres — matrix reports are a fixed character grid, printed at
* whatever physical size the browser's print scaling produces, same as the
* on-screen editor already assumes (EditorCanvas.jsx's own CHAR_WIDTH/
* CHAR_HEIGHT constants).
*
* `.print-root` here is intentionally the SAME class name a4/print/print.css
* defines (`display:none` on screen, `display:block!important` under
* `@media print`, with `body > *:not(.print-root)` hiding everything else).
* Both matrix and A4 print flows append their container directly to <body>
* with this class, and only one print can be in flight at a time, so sharing
* the rule is correct, not a collision — redefining it here would be
* redundant, not wrong, but ReportPreviewModal.jsx already transitively loads
* a4/print/print.css (via its PrintDocument import), so this file does not
* repeat that rule.
*/
.matrix-print-page {
position: relative;
width: 1440px; /* 80 cols * 18px, matches EditorCanvas GRID_COLS/CHAR_WIDTH */
background: #fff;
font-family: 'Courier New', 'Consolas', monospace;
font-size: 32px;
line-height: 32px;
white-space: pre;
letter-spacing: -1.2px;
font-variant-ligatures: none;
}
.matrix-print-el {
position: absolute;
box-sizing: content-box;
margin: 0;
padding: 0;
white-space: pre;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.matrix-print-el--dbtext,
.matrix-print-el--expr,
.matrix-print-el--text {
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
}
.matrix-print-frame-line {
white-space: pre;
line-height: 32px;
}
+136 -27
View File
@@ -1,7 +1,7 @@
.report-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
background: var(--backdrop);
display: flex;
align-items: center;
justify-content: center;
@@ -9,54 +9,163 @@
}
.report-preview-modal {
background: #fff;
border-radius: 4px;
background: var(--surface);
border-radius: var(--radius);
display: flex;
flex-direction: column;
max-width: 95vw;
width: 90vw;
max-width: 1000px;
max-height: 95vh;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-overlay);
}
.report-preview-header {
.report-preview-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid #ddd;
font-weight: 600;
font-size: 14px;
padding: var(--space-2-5) var(--space-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.report-preview-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 0 4px;
color: #555;
.report-preview-switcher {
display: flex;
gap: var(--space-1);
}
.report-preview-close:hover {
color: #000;
.report-preview-switch-btn {
padding: var(--space-1-5) var(--space-4);
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.report-preview-switch-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--border-strong);
}
.report-preview-switch-btn--active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.report-preview-switch-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.report-preview-download-btn {
padding: var(--space-1-5) var(--space-4);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.report-preview-download-btn:hover:not(:disabled) {
color: var(--text);
border-color: var(--border-strong);
}
.report-preview-download-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.report-preview-body {
overflow: auto;
flex: 1;
padding: 8px;
padding: var(--space-2);
background: var(--surface-sunken);
}
.report-preview-error {
padding: 24px;
color: #c00;
font-size: 14px;
padding: var(--space-6);
color: var(--danger);
font-size: var(--fs-md);
}
.report-preview-loading {
padding: 24px;
color: #666;
font-size: 14px;
padding: var(--space-6);
color: var(--text-muted);
font-size: var(--fs-md);
}
.report-preview-empty {
padding: var(--space-10) var(--space-6);
text-align: center;
color: var(--text-muted);
font-size: var(--fs-md);
}
.report-preview-design-btn {
margin-top: var(--space-3);
padding: var(--space-2) var(--space-4);
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: var(--accent-contrast);
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
cursor: pointer;
}
/* A4 body: PrintDocument renders true millimetres, so it is scaled down to
fit the modal rather than shown at physical size. transform-origin keeps
it anchored top-left; the wrapper's own height is set to match the scaled
result so the surrounding flex layout doesn't leave a tall empty gap. */
.report-preview-a4-scale {
transform: scale(0.6);
transform-origin: top left;
width: 210mm;
}
.report-preview-footer {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.report-preview-print-btn {
padding: var(--space-2) var(--space-6);
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: var(--accent-contrast);
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.report-preview-print-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.report-preview-close-btn {
padding: var(--space-2) var(--space-6);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.report-preview-close-btn:hover {
border-color: var(--border-strong);
}
+213 -19
View File
@@ -1,11 +1,22 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { DataProvider, useReportData } from './ReportEditor/DataContext';
import { BandProvider } from './ReportEditor/BandContext';
import EditorCanvas from './ReportEditor/EditorCanvas';
import PrintDocument from './ReportEditor/a4/print/PrintDocument';
import MatrixPrintDocument from './ReportEditor/print/MatrixPrintDocument';
import { layout } from './ReportEditor/a4/layout';
import api from '../services/api';
import './ReportPreviewModal.css';
function PreviewContent({ vehicleData, elements }) {
/**
* Renders the matrix report via the existing EditorCanvas preview machinery.
* Reused both for the modal body and (via printMatrixReport below) inside the
* print-root container, so the EditorCanvas wiring exists in one place.
*/
function MatrixPreview({ vehicleData, elements }) {
const { setData } = useReportData();
useEffect(() => {
@@ -31,42 +42,225 @@ function PreviewContent({ vehicleData, elements }) {
);
}
/**
* Prints the matrix report using the same print-root-append pattern
* printA4Report established: a container carrying the `print-root` class is
* appended directly to <body> (so print.css's `body > *:not(.print-root)`
* hiding rule matches it), rendered synchronously via flushSync so
* window.print() never sees an empty container, then torn down on
* afterprint (or a fallback timeout, since afterprint is not guaranteed to
* fire).
*
* Renders via MatrixPrintDocument (a pure, non-interactive renderer, mirroring
* a4/print/PrintDocument.jsx), NOT the MatrixPreview/EditorCanvas machinery
* used for the on-screen body above — the print output should not carry any
* editor-only affordances (selection, drag, resize handles), and a plain
* function component needs no DataProvider/BandProvider context to run.
*/
function printMatrixReport(elements, vehicleData) {
const container = document.createElement('div');
container.className = 'print-root';
document.body.appendChild(container);
const root = createRoot(container);
flushSync(() => {
root.render(<MatrixPrintDocument elements={elements} vehicleData={vehicleData} />);
});
return new Promise(resolve => {
let done = false;
let fallbackTimer = null;
const cleanup = () => {
if (done) return;
done = true;
window.removeEventListener('afterprint', cleanup);
clearTimeout(fallbackTimer);
setTimeout(() => {
try {
root.unmount();
} catch (err) {
console.error('Matrix print teardown failed to unmount cleanly:', err);
}
container.remove();
resolve();
}, 0);
};
window.addEventListener('afterprint', cleanup);
fallbackTimer = setTimeout(cleanup, 60000);
try {
window.print();
} catch (err) {
console.error('Print failed:', err);
cleanup();
}
});
}
export default function ReportPreviewModal({ vehicleData, onClose }) {
const [elements, setElements] = useState(null);
const [error, setError] = useState('');
const navigate = useNavigate();
const vehicleId = vehicleData?.vehicle?.id;
// Both kind-variants of notice_report, fetched together so the switcher
// can toggle without a re-fetch. null = not loaded yet, undefined entry =
// that kind doesn't exist.
const [reports, setReports] = useState(null);
const [loadError, setLoadError] = useState('');
const [activeKind, setActiveKind] = useState(
() => localStorage.getItem('noticeReportLastKind') || 'matrix'
);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
api.get('/api/reports/')
api.get('/api/reports/?name=notice_report')
.then(res => {
const reports = res.data.results || res.data;
const report = reports.find(r => r.name === 'notice_report');
if (report) {
setElements(report.elements || []);
} else {
setError('Report "notice_report" not found. Create it in the Report Editor first.');
const list = res.data.results || res.data || [];
const byKind = { matrix: null, a4: null };
for (const r of list) {
if (r.kind === 'a4') byKind.a4 = r;
else byKind.matrix = r;
}
setReports(byKind);
// Default to the last-used kind if it exists, else whichever
// kind is actually saved.
setActiveKind(prev => {
if (byKind[prev]) return prev;
return byKind.matrix ? 'matrix' : byKind.a4 ? 'a4' : prev;
});
if (!byKind.matrix && !byKind.a4) {
setLoadError('Report "notice_report" not found. Create it in the Report Editor first.');
}
})
.catch(() => setError('Failed to load report'));
.catch(() => setLoadError('Failed to load report'));
}, []);
const activeReport = reports ? reports[activeKind] : null;
// A4 layout is pure and cheap to recompute on kind/data change.
const a4Pages = useMemo(() => {
if (activeKind !== 'a4' || !activeReport) return null;
const pageSetup = activeReport.pageSetup || activeReport.page_setup || {};
return layout(activeReport.elements, vehicleData, pageSetup).pages;
}, [activeKind, activeReport, vehicleData]);
const switchKind = (kind) => {
setActiveKind(kind);
localStorage.setItem('noticeReportLastKind', kind);
};
const goDesign = (kind) => {
const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor';
navigate(route, { state: { reportName: 'notice_report', vehicleData } });
};
const markNoticePrinted = async () => {
if (!vehicleId) return;
try {
await api.post(`/api/vehicles/${vehicleId}/mark-notice-printed/`);
} catch (err) {
console.error('Failed to mark notice as printed:', err);
}
};
// Triggered right when Print/Download PDF is clicked, not gated on the
// print flow's own completion - a physically failed print (paper jam,
// offline printer) must still move the vehicle to History, where it's
// one click to reprint, rather than stranding it silently in the active
// list.
//
// Print and Download PDF are the SAME flow: window.print() opens the
// browser's print dialog either way, and "Save as PDF" is simply the
// destination the operator picks there - see the finalize-history-void
// plan's Phase D decision (no server-side rendering, no PDF library, so
// the output always uses the station's own installed fonts). One shared
// handler avoids two call sites drifting apart.
const handlePrintOrDownload = async () => {
if (isPrinting) return;
setIsPrinting(true);
try {
if (activeKind === 'a4') {
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
await printA4Report(activeReport, vehicleData);
} else {
await printMatrixReport(activeReport.elements || [], vehicleData);
}
await markNoticePrinted();
} catch (err) {
console.error('Print failed:', err);
} finally {
setIsPrinting(false);
}
};
const kindLabel = { matrix: 'Matrix', a4: 'A4' };
return (
<div className="report-preview-overlay" onClick={onClose}>
<div className="report-preview-modal" onClick={e => e.stopPropagation()}>
<div className="report-preview-header">
<span>Notice Report — {vehicleData.vehicle.vehicle_number}</span>
<button className="report-preview-close" onClick={onClose}>×</button>
<div className="report-preview-topbar">
<div className="report-preview-switcher" role="tablist">
{['matrix', 'a4'].map(kind => (
<button
key={kind}
role="tab"
aria-selected={activeKind === kind}
className={`report-preview-switch-btn ${activeKind === kind ? 'report-preview-switch-btn--active' : ''}`}
onClick={() => switchKind(kind)}
disabled={!reports?.[kind]}
title={reports?.[kind] ? undefined : `No ${kindLabel[kind]} version designed yet`}
>
{kindLabel[kind]}
</button>
))}
</div>
<button
className="report-preview-download-btn"
onClick={handlePrintOrDownload}
disabled={!activeReport || isPrinting}
title="Opens the print dialog - choose 'Save as PDF' to download"
>
Download PDF
</button>
</div>
<div className="report-preview-body">
{error ? (
<div className="report-preview-error">{error}</div>
) : elements === null ? (
{loadError ? (
<div className="report-preview-error">{loadError}</div>
) : reports === null ? (
<div className="report-preview-loading">Loading...</div>
) : !activeReport ? (
<div className="report-preview-empty">
<p>No {kindLabel[activeKind]} version of "notice_report" has been designed yet.</p>
<button className="report-preview-design-btn" onClick={() => goDesign(activeKind)}>
Design {kindLabel[activeKind]} Notice
</button>
</div>
) : activeKind === 'a4' ? (
<div className="report-preview-a4-scale">
<PrintDocument pages={a4Pages || []} pageSetup={activeReport.pageSetup || activeReport.page_setup || {}} />
</div>
) : (
<DataProvider>
<PreviewContent vehicleData={vehicleData} elements={elements} />
<MatrixPreview vehicleData={vehicleData} elements={activeReport.elements || []} />
</DataProvider>
)}
</div>
<div className="report-preview-footer">
<button
className="report-preview-print-btn"
onClick={handlePrintOrDownload}
disabled={!activeReport || isPrinting}
>
{isPrinting ? 'Printing...' : 'Print'}
</button>
<button className="report-preview-close-btn" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
@@ -1,10 +1,10 @@
.scale-monitor {
min-height: 100vh;
background-color: #f5f5f7;
flex: 1 0 auto; /* fill the rail layout's content column */
background-color: var(--bg);
}
.sm-body {
padding: 20px;
padding: var(--space-5);
max-width: 1400px;
margin: 0 auto;
}
@@ -12,65 +12,65 @@
.sm-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
gap: var(--space-3);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.sm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
padding: var(--space-1-5) var(--space-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: #666;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
font-size: var(--fs-base);
}
.sm-back:hover {
border-color: #667eea;
color: #667eea;
border-color: var(--accent);
color: var(--accent);
}
.sm-conn {
font-size: 12px;
font-weight: 600;
padding: 3px 8px;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
padding: var(--space-1) var(--space-2);
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.sm-conn--on {
background-color: #e8f5e9;
color: #2e7d32;
background-color: var(--success-soft);
color: var(--success);
}
.sm-conn--off {
background-color: #fee;
color: #c62828;
background-color: var(--danger-soft);
color: var(--danger);
}
.sm-status {
font-size: 12px;
color: #888;
font-family: 'Courier New', monospace;
font-size: var(--fs-sm);
color: var(--text-muted);
font-family: var(--font-mono);
}
.sm-message {
padding: 8px 12px;
background-color: #eef0ff;
border: 1px solid #ccd;
padding: var(--space-2) var(--space-3);
background-color: var(--accent-soft);
border: 1px solid var(--border-strong);
border-radius: 6px;
color: #445;
font-size: 13px;
margin-bottom: 12px;
color: var(--text);
font-size: var(--fs-base);
margin-bottom: var(--space-3);
}
.sm-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
gap: var(--space-4);
}
@media (max-width: 1000px) {
@@ -80,74 +80,74 @@
}
.sm-panel {
background: white;
background: var(--surface);
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
padding: var(--space-4);
box-shadow: 0 1px 3px var(--shadow-card);
}
.sm-panel--wide {
margin-top: 16px;
margin-top: var(--space-4);
}
.sm-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: var(--space-3);
}
.sm-panel-head h3 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #888;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-panel-actions {
display: flex;
gap: 6px;
gap: var(--space-1-5);
}
.sm-panel-actions button {
padding: 4px 10px;
padding: var(--space-1) var(--space-2-5);
background: none;
border: 1px solid #ddd;
border: 1px solid var(--border);
border-radius: 6px;
color: #666;
font-size: 12px;
color: var(--text-muted);
font-size: var(--fs-sm);
cursor: pointer;
}
.sm-panel-actions button:hover {
border-color: #667eea;
color: #667eea;
border-color: var(--accent);
color: var(--accent);
}
.sm-send {
display: flex;
gap: 6px;
margin-bottom: 10px;
gap: var(--space-1-5);
margin-bottom: var(--space-2-5);
}
.sm-send input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
padding: var(--space-1-5) var(--space-2-5);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
font-size: var(--fs-base);
font-family: var(--font-mono);
}
.sm-send button {
padding: 6px 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
padding: var(--space-1-5) var(--space-4);
background: linear-gradient(135deg, var(--accent), var(--accent));
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
color: var(--accent-contrast);
font-size: var(--fs-base);
cursor: pointer;
}
@@ -159,32 +159,32 @@
.sm-lines {
height: 420px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border: 1px solid var(--border);
border-radius: 6px;
background-color: #fafafa;
background-color: var(--surface-sunken);
}
.sm-line {
padding: 5px 8px;
border-bottom: 1px solid #eee;
padding: var(--space-1-5) var(--space-2);
border-bottom: 1px solid var(--border);
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--fs-sm);
}
.sm-line:hover {
background-color: #f0f0ff;
background-color: var(--accent-soft);
}
.sm-line--selected {
background-color: #e8eaff;
background-color: var(--accent-soft);
}
.sm-line-text {
display: flex;
justify-content: space-between;
gap: 8px;
color: #333;
gap: var(--space-2);
color: var(--text);
}
.sm-visible-space {
@@ -193,88 +193,88 @@
}
.sm-len {
color: #aaa;
color: var(--border-strong);
flex-shrink: 0;
}
.sm-line-hex {
color: #999;
font-size: 11px;
color: var(--text-muted);
font-size: var(--fs-xs);
word-break: break-all;
margin-top: 2px;
margin-top: var(--space-0-5);
}
.sm-empty {
padding: 20px;
padding: var(--space-5);
text-align: center;
color: #999;
color: var(--text-muted);
font-style: italic;
font-size: 13px;
font-size: var(--fs-base);
}
.sm-form {
display: flex;
flex-direction: column;
gap: 10px;
gap: var(--space-2-5);
}
.sm-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: var(--space-2-5);
}
.sm-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
gap: var(--space-1);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm-form input[type="text"],
.sm-form input[type="number"] {
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;
font-size: var(--fs-md);
text-transform: none;
letter-spacing: normal;
font-weight: 400;
color: #333;
font-weight: var(--fw-regular);
color: var(--text);
}
.sm-check {
flex-direction: row !important;
align-items: center;
gap: 6px;
gap: var(--space-1-5);
text-transform: none !important;
letter-spacing: normal !important;
font-weight: 400 !important;
color: #555 !important;
font-size: 12px !important;
font-weight: var(--fw-regular) !important;
color: var(--text) !important;
font-size: var(--fs-sm) !important;
}
.sm-hint {
font-size: 11px;
color: #999;
font-size: var(--fs-xs);
color: var(--text-muted);
font-style: italic;
}
.sm-preview {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #eee;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--border);
}
.sm-preview h4 {
margin: 0 0 8px 0;
font-size: 11px;
font-weight: 600;
color: #888;
margin: 0 0 var(--space-2) 0;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -282,82 +282,82 @@
.sm-preview-ok {
display: flex;
align-items: baseline;
gap: 10px;
gap: var(--space-2-5);
}
.sm-preview-weight {
font-size: 24px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: #333;
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
font-family: var(--font-mono);
color: var(--text);
}
.sm-bal {
color: #2e7d32;
font-size: 12px;
font-weight: 600;
color: var(--success);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
}
.sm-unbal {
color: #c62828;
font-size: 12px;
font-weight: 600;
color: var(--danger);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
}
.sm-preview-error {
color: #c62828;
font-size: 13px;
color: var(--danger);
font-size: var(--fs-base);
font-style: italic;
}
.sm-save {
margin-top: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #667eea, #764ba2);
margin-top: var(--space-3);
padding: var(--space-2) var(--space-4);
background: linear-gradient(135deg, var(--accent), var(--accent));
border: none;
border-radius: 6px;
color: white;
font-size: 13px;
font-weight: 600;
color: var(--accent-contrast);
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
cursor: pointer;
}
.sm-save:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
box-shadow: 0 3px 8px var(--accent-soft);
}
.sm-protocols {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--space-1-5);
}
.sm-protocol {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #eee;
gap: var(--space-2-5);
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-size: var(--fs-base);
}
.sm-protocol:hover {
background-color: #f0f0ff;
background-color: var(--accent-soft);
}
.sm-protocol-name {
font-weight: 600;
color: #333;
font-weight: var(--fw-semibold);
color: var(--text);
}
.sm-protocol-kind,
.sm-protocol-poll {
font-size: 11px;
color: #999;
font-family: 'Courier New', monospace;
font-size: var(--fs-xs);
color: var(--text-muted);
font-family: var(--font-mono);
}
.sm-protocol-poll {
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import useSerialData from '../../hooks/useSerialData';
import { useScaleProtocols } from '../../contexts/ScaleProtocolContext';
import { parseReading, formatHex } from '../../services/scaleProtocols';
@@ -120,7 +119,6 @@ export default function ScaleMonitor() {
return (
<div className="scale-monitor">
<Header />
<div className="sm-body">
<div className="sm-toolbar">
<button className="sm-back" onClick={() => navigate('/')}>&larr; Back</button>
@@ -4,12 +4,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: fadeIn 0.2s ease-in;
}
@@ -23,9 +23,9 @@
}
.overlay-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: 480px;
max-height: 90vh;
@@ -45,8 +45,8 @@
}
.overlay-header {
padding: 24px 24px 16px;
border-bottom: 2px solid #667eea;
padding: var(--space-6) var(--space-6) var(--space-4);
border-bottom: 2px solid var(--accent);
display: flex;
justify-content: space-between;
align-items: center;
@@ -54,15 +54,15 @@
.overlay-header h2 {
margin: 0;
color: #667eea;
font-size: 24px;
color: var(--accent);
font-size: var(--fs-2xl);
}
.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;
@@ -75,84 +75,84 @@
}
.close-button:hover {
background: #f5f5f5;
color: #333;
background: var(--surface-sunken);
color: var(--text);
}
.password-form {
padding: 24px;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: 20px;
gap: var(--space-5);
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--space-2);
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
font-weight: var(--fw-semibold);
color: var(--text);
font-size: var(--fs-md);
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
padding: var(--space-3) var(--space-4);
border: 2px solid var(--border);
border-radius: 8px;
font-size: 16px;
font-size: var(--fs-lg);
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
border-color: var(--accent);
}
.form-group input:disabled {
background-color: #f5f5f5;
background-color: var(--surface-sunken);
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
gap: var(--space-3);
margin-top: var(--space-2);
}
.cancel-button,
.submit-button {
flex: 1;
padding: 12px 24px;
padding: var(--space-3) var(--space-6);
border-radius: 8px;
font-size: 16px;
font-weight: 600;
font-size: var(--fs-lg);
font-weight: var(--fw-semibold);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.cancel-button {
background: white;
border: 2px solid #ddd;
color: #666;
background: var(--surface);
border: 2px solid var(--border);
color: var(--text-muted);
}
.cancel-button:hover:not(:disabled) {
border-color: #999;
color: #333;
border-color: var(--border-strong);
color: var(--text);
}
.submit-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--accent-gradient);
border: none;
color: white;
color: var(--accent-contrast);
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
box-shadow: 0 4px 12px var(--accent-soft);
}
.cancel-button:disabled,
@@ -162,16 +162,16 @@
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
background-color: var(--danger-soft);
color: var(--danger);
padding: var(--space-3) var(--space-4);
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
border: 1px solid var(--danger);
font-size: var(--fs-md);
}
.success-message {
padding: 40px 24px;
padding: var(--space-10) var(--space-6);
text-align: center;
}
@@ -179,13 +179,13 @@
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
background: var(--accent-gradient);
color: var(--accent-contrast);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin: 0 auto 16px;
font-size: var(--fs-display);
margin: 0 auto var(--space-4);
animation: scaleIn 0.4s ease-out;
}
@@ -200,9 +200,9 @@
.success-message p {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 500;
color: var(--text);
font-size: var(--fs-xl);
font-weight: var(--fw-medium);
}
@media (max-width: 540px) {
@@ -211,7 +211,7 @@
}
.password-form {
padding: 20px;
padding: var(--space-5);
}
.form-actions {
+40 -40
View File
@@ -3,88 +3,88 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
background: var(--accent-gradient);
padding: var(--space-5);
}
.login-card {
background: white;
background: var(--surface);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-overlay);
width: 100%;
max-width: 400px;
padding: 40px;
padding: var(--space-10);
}
.login-header {
text-align: center;
margin-bottom: 30px;
margin-bottom: var(--space-8);
}
.login-header h1 {
margin: 0 0 10px 0;
color: #667eea;
font-size: 32px;
margin: 0 0 var(--space-2-5) 0;
color: var(--accent);
font-size: var(--fs-display);
}
.login-header p {
margin: 0;
color: #666;
font-size: 16px;
color: var(--text-muted);
font-size: var(--fs-lg);
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
gap: var(--space-5);
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--space-2);
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
font-weight: var(--fw-semibold);
color: var(--text);
font-size: var(--fs-md);
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
padding: var(--space-3) var(--space-4);
border: 2px solid var(--border);
border-radius: 8px;
font-size: 16px;
font-size: var(--fs-lg);
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
border-color: var(--accent);
}
.form-group input:disabled {
background-color: #f5f5f5;
background-color: var(--surface-sunken);
cursor: not-allowed;
}
.login-button {
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: var(--space-4) var(--space-6);
background: var(--accent-gradient);
color: var(--accent-contrast);
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
font-size: var(--fs-lg);
font-weight: var(--fw-semibold);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 10px;
margin-top: var(--space-2-5);
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
box-shadow: 0 4px 12px var(--accent-soft);
}
.login-button:active:not(:disabled) {
@@ -97,12 +97,12 @@
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
background-color: var(--danger-soft);
color: var(--danger);
padding: var(--space-3) var(--space-4);
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
border: 1px solid var(--danger);
font-size: var(--fs-md);
}
.loading {
@@ -111,18 +111,18 @@
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
background: var(--accent-gradient);
color: var(--accent-contrast);
}
.loading-spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border: 4px solid color-mix(in srgb, var(--accent-contrast) 30%, transparent);
border-top: 4px solid var(--accent-contrast);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 10px;
margin-bottom: var(--space-2-5);
}
@keyframes spin {
@@ -132,10 +132,10 @@
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
padding: var(--space-8) var(--space-5);
}
.login-header h1 {
font-size: 28px;
font-size: var(--fs-3xl);
}
}
@@ -0,0 +1,116 @@
/* The form and buttons reuse Main.css classes (.extra-field,
.vehicle-form-actions, .extra-save-btn ...); the compound selectors below
exist only to outrank those where this screen differs. */
.um-page {
padding: var(--space-6);
max-width: 900px;
}
.um-toolbar {
margin-bottom: var(--space-4);
}
.um-add-form {
margin-bottom: var(--space-6);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-sunken);
}
.um-add-form-title {
margin: 0 0 var(--space-4) 0;
font-size: var(--fs-md);
color: var(--accent);
font-weight: var(--fw-semibold);
}
.extra-field.um-permission-field {
flex-direction: row;
align-items: center;
gap: var(--space-2);
}
/* Three classes deep to outrank the cockpit `.extra-field label` rule */
.um-add-form .extra-field.um-permission-field label {
text-transform: none;
letter-spacing: normal;
cursor: pointer;
}
.vehicle-form-actions.um-form-actions {
margin-top: var(--space-4);
padding-top: var(--space-3);
}
.um-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-md);
}
.um-table thead tr {
border-bottom: 2px solid var(--border);
text-align: left;
}
.um-table th,
.um-table td {
padding: var(--space-2-5) var(--space-3);
}
.um-table th {
color: var(--text-muted);
font-weight: var(--fw-semibold);
}
.um-row {
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.um-row--inactive {
background: var(--surface-sunken);
}
.um-table .um-username {
font-weight: var(--fw-semibold);
color: var(--text);
}
.um-table .um-cell-center {
text-align: center;
}
.um-status {
display: inline-block;
padding: var(--space-0-5) var(--space-2);
border-radius: 10px;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
background: var(--success-soft);
color: var(--success);
}
.um-status--inactive {
background: var(--danger-soft);
color: var(--danger);
}
.um-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.um-table .um-action-btn {
font-size: var(--fs-sm);
padding: var(--space-1-5) var(--space-3);
}
.um-table .um-empty {
padding: var(--space-5);
text-align: center;
color: var(--text-muted);
}
+24 -35
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import api from '../../services/api';
import Header from '../Header';
import './UserManager.css';
const PERMISSION_FIELDS = [
{ key: 'can_measure', label: 'Can Measure' },
@@ -86,13 +86,12 @@ export default function UserManager() {
return (
<div>
<Header />
<div style={{ padding: '24px', maxWidth: '900px' }}>
<div className="um-page">
<h2 className="vehicle-detail-title">User Manager</h2>
{error && <div className="vehicle-error">{error}</div>}
<div style={{ marginBottom: '16px' }}>
<div className="um-toolbar">
<button
className="vehicle-add-btn"
onClick={() => setAddFormOpen(prev => !prev)}
@@ -102,8 +101,8 @@ export default function UserManager() {
</div>
{addFormOpen && (
<form onSubmit={handleAddUser} style={{ marginBottom: '24px', padding: '16px', border: '1px solid #e0e0e0', borderRadius: '8px', background: '#fafafa' }}>
<h3 style={{ margin: '0 0 14px 0', fontSize: '14px', color: '#667eea', fontWeight: 600 }}>New User</h3>
<form onSubmit={handleAddUser} className="um-add-form">
<h3 className="um-add-form-title">New User</h3>
<div className="extra-fields">
<div className="extra-field">
<label>Username</label>
@@ -125,18 +124,18 @@ export default function UserManager() {
/>
</div>
{PERMISSION_FIELDS.map(({ key, label }) => (
<div key={key} className="extra-field" style={{ flexDirection: 'row', alignItems: 'center', gap: '8px' }}>
<div key={key} className="extra-field um-permission-field">
<input
type="checkbox"
id={`new-${key}`}
checked={newUser[key]}
onChange={e => setNewUser(prev => ({ ...prev, [key]: e.target.checked }))}
/>
<label htmlFor={`new-${key}`} style={{ textTransform: 'none', letterSpacing: 'normal', cursor: 'pointer' }}>{label}</label>
<label htmlFor={`new-${key}`}>{label}</label>
</div>
))}
</div>
<div className="vehicle-form-actions" style={{ marginTop: '14px', paddingTop: '12px' }}>
<div className="vehicle-form-actions um-form-actions">
<button
type="button"
className="vehicle-form-cancel"
@@ -155,60 +154,50 @@ export default function UserManager() {
</form>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
<table className="um-table">
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0', textAlign: 'left' }}>
<th style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>Username</th>
<th style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>Status</th>
<tr>
<th>Username</th>
<th>Status</th>
{PERMISSION_FIELDS.map(({ key, label }) => (
<th key={key} style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>{label}</th>
<th key={key}>{label}</th>
))}
<th style={{ padding: '10px 12px', color: '#555', fontWeight: 600 }}>Actions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ borderBottom: '1px solid #f0f0f0', background: u.is_active ? 'white' : '#fafafa' }}>
<td style={{ padding: '10px 12px', fontWeight: 600, color: '#333' }}>{u.username}</td>
<td style={{ padding: '10px 12px' }}>
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '10px',
fontSize: '12px',
fontWeight: 600,
background: u.is_active ? '#e8f5e9' : '#fce4ec',
color: u.is_active ? '#2e7d32' : '#c62828',
}}>
<tr key={u.id} className={u.is_active ? 'um-row' : 'um-row um-row--inactive'}>
<td className="um-username">{u.username}</td>
<td>
<span className={u.is_active ? 'um-status' : 'um-status um-status--inactive'}>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
{PERMISSION_FIELDS.map(({ key }) => (
<td key={key} style={{ padding: '10px 12px', textAlign: 'center' }}>
<td key={key} className="um-cell-center">
<input
type="checkbox"
checked={!!u[key]}
onChange={e => handlePermissionChange(u.id, key, e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
className="um-checkbox"
/>
</td>
))}
<td style={{ padding: '10px 12px' }}>
<td>
{u.is_active ? (
<button
className="vehicle-form-cancel"
className="vehicle-form-cancel um-action-btn"
onClick={() => handleDeactivate(u.id)}
disabled={u.id === currentUser?.id}
title={u.id === currentUser?.id ? 'Cannot deactivate yourself' : undefined}
style={{ fontSize: '12px', padding: '5px 12px' }}
>
Deactivate
</button>
) : (
<button
className="extra-save-btn"
className="extra-save-btn um-action-btn"
onClick={() => handleReactivate(u.id)}
style={{ fontSize: '12px', padding: '5px 12px' }}
>
Reactivate
</button>
@@ -218,7 +207,7 @@ export default function UserManager() {
))}
{users.length === 0 && (
<tr>
<td colSpan={PERMISSION_FIELDS.length + 3} style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<td colSpan={PERMISSION_FIELDS.length + 3} className="um-empty">
No users found
</td>
</tr>
@@ -0,0 +1,158 @@
import { useLayoutEffect, useRef } from 'react';
/**
* Visuals for the staged vehicle list (hooks/useStagedList), see Main.css.
*
* A new row is announced by a marker line (pending, `delay` ms): calm with a
* travelling shimmer, then for the last `distortMs` (the hook's `distorting`
* phase) it wobbles like a plucked string and bulges open into a lens. The
* row then opens out of that lens (entering, `enterMs`) and its plate is
* revealed character by character. While the list is held the marker stays
* calm, so the distortion starts when the hook says so, not at a fixed time.
*
* The timings are handed to the CSS as custom properties (listStagingStyle),
* so the hook's timers and the animations come from the same numbers.
*/
export const LIST_STAGING = {
delay: 3000, // marker shown before the row opens
distortMs: 650, // the tail of `delay` in which the marker distorts
enterMs: 800, // row opens, plate and weight are revealed
exitMs: 280, // a leaving row collapses
releaseStagger: 70, // between held changes going on, top to bottom
};
/* ----- Hover hold ----------------------------------------------------------
While a mouse/pen is over the list, staged changes wait. A click on a row
releases the hold at the click point; it only holds again once the pointer
has moved REHOLD_PX away from there, so the jitter of a click does not
re-hold at once. Touch never holds. */
export const REHOLD_PX = 4;
// Whether a pointer event over the list should (re)take the hold, given the
// point a click released it at (null when not in the post-click state).
export function shouldHoldList({ pointerType, clientX, clientY }, releasedAt) {
if (pointerType !== 'mouse' && pointerType !== 'pen') return false;
if (!releasedAt) return true;
return Math.hypot(clientX - releasedAt.x, clientY - releasedAt.y) >= REHOLD_PX;
}
export const listStagingStyle = {
'--vl-enter-ms': `${LIST_STAGING.enterMs}ms`,
'--vl-exit-ms': `${LIST_STAGING.exitMs}ms`,
};
/* ----- Marker line ---------------------------------------------------------
One closed path, top edge left->right then bottom edge back. While both
edges coincide it reads as a single line (zero area, so the fill is
invisible); the last frame pulls them apart into a filled lens, which the
entering row's overlay picks up. Every frame has the same commands, so SMIL
can interpolate `d` (CSS `d: path()` does not animate in Firefox). The
viewBox is stretched to the marker's width; y units are pixels. */
const W = 100;
const H = 12; // = .vehicle-list-marker-line height
const MID = H / 2;
const SEGS = 8;
const fmt = n => Math.round(n * 100) / 100;
// Cubic Bezier segments sampling f between x0 and x1 (Hermite tangents).
function edge(f, x0, x1) {
const h = (x1 - x0) / SEGS;
const slope = x => (f(x + 0.01) - f(x - 0.01)) / 0.02;
let d = '';
for (let i = 0; i < SEGS; i++) {
const a = x0 + i * h, b = a + h;
d += ` C${fmt(a + h / 3)} ${fmt(f(a) + slope(a) * h / 3)}`
+ ` ${fmt(b - h / 3)} ${fmt(f(b) - slope(b) * h / 3)}`
+ ` ${fmt(b)} ${fmt(f(b))}`;
}
return d;
}
const shape = (top, bottom) => `M0 ${fmt(top(0))}${edge(top, 0, W)}${edge(bottom, W, 0)}Z`;
// A string pinned at both ends: a standing wave (modes 3 + 2) and a bulge.
const wave = a => x => MID + a * (0.8 * Math.sin(3 * Math.PI * x / W) + 0.3 * Math.sin(2 * Math.PI * x / W));
const bulge = a => x => a * Math.sin(Math.PI * x / W);
const line = a => shape(wave(a), wave(a));
const lens = (open, a) => shape(x => wave(a)(x) - bulge(open)(x), x => wave(a)(x) + bulge(open)(x));
const FLAT = line(0);
const DISTORT_FRAMES = [FLAT, line(0.7), line(-1.3), line(1.9), line(-2.4), lens(2.4, -0.9), lens(4, 0)];
const DISTORT_TIMES = [0, 0.16, 0.32, 0.48, 0.64, 0.82, 1];
const DISTORT_SPLINES = DISTORT_FRAMES.slice(1).map(() => '.45 0 .55 1').join(';');
// The morph waits (begin="indefinite") until `distorting` turns on, then is
// started with beginElement(); the CSS wake and shimmer-out key off the
// --distorting class at the same moment.
export function PendingMarker({ reduceMotion, distorting = false }) {
const animateRef = useRef(null);
useLayoutEffect(() => {
if (distorting) animateRef.current?.beginElement?.();
}, [distorting]);
return (
<div className={`vehicle-list-marker${distorting ? ' vehicle-list-marker--distorting' : ''}`} aria-hidden="true">
<svg
className="vehicle-list-marker-line"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
>
<path d={FLAT}>
{!reduceMotion && (
<animate
ref={animateRef}
attributeName="d"
begin="indefinite"
dur={`${LIST_STAGING.distortMs}ms`}
fill="freeze"
calcMode="spline"
values={DISTORT_FRAMES.join(';')}
keyTimes={DISTORT_TIMES.join(';')}
keySplines={DISTORT_SPLINES}
/>
)}
</path>
</svg>
</div>
);
}
/* ----- Plate reveal --------------------------------------------------------
Characters start once the row is mostly open and finish just before the
entering phase ends; long plates get a tighter stagger. */
const REVEAL_AT = 0.36; // of enterMs
const CHAR_SHARE = 0.3; // one char's animation, of enterMs (also in CSS)
const MAX_STEP = 0.045;
export function RevealPlate({ text }) {
const chars = [...text];
const t = LIST_STAGING.enterMs;
const step = Math.min(t * MAX_STEP, (t * (0.97 - REVEAL_AT - CHAR_SHARE)) / Math.max(chars.length - 1, 1));
return (
<span className="vehicle-list-number" aria-label={text}>
{chars.map((c, i) => (
<span
key={i}
className="vehicle-list-char"
aria-hidden="true"
style={{ animationDelay: `${Math.round(t * REVEAL_AT + i * step)}ms` }}
>
{c}
</span>
))}
</span>
);
}
// The opening animates `height` to the row's natural height, measured here
// before the first paint (with its animation briefly off). Module-level so it
// runs once per entering row.
export function measureOpeningRow(el) {
if (!el) return;
el.style.animationName = 'none';
const h = el.offsetHeight;
el.style.animationName = '';
if (h > 0) el.style.setProperty('--vl-row-h', `${h}px`);
}
@@ -0,0 +1,165 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import NavRail from '../NavRail';
import { getLicenseWarning } from '../licenseWarning';
global.IS_REACT_ACT_ENVIRONMENT = true;
const mocks = vi.hoisted(() => ({ user: null, status: null }));
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({ currentUser: mocks.user, logout: () => {} }),
}));
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({ theme: 'classic', toggleTheme: () => {} }),
}));
vi.mock('../../contexts/LicenseContext', () => ({
useLicense: () => ({ status: mocks.status }),
}));
vi.mock('../Users/ChangePasswordOverlay', () => ({ default: () => null }));
vi.mock('../NomenclatureManager/NomenclatureManager', () => ({ default: () => null }));
const ADMIN = { username: 'admin', is_admin: true, role: 'employee' };
const OPERATOR = { username: 'op', is_admin: false, role: 'employee' };
const GRACE = { valid: false, in_grace: true, grace_days_left: 12, stations: 1 };
const BLOCKED = { valid: false, in_grace: false, grace_days_left: 0, stations: 1 };
const VALID = { valid: true, in_grace: false, grace_days_left: 0, stations: 3 };
describe('getLicenseWarning', () => {
it('returns nothing for a valid or not-yet-loaded licence', () => {
expect(getLicenseWarning(null)).toBeNull();
expect(getLicenseWarning(VALID)).toBeNull();
});
it('builds the grace message with the day count as the emphasis', () => {
const w = getLicenseWarning(GRACE);
expect(w.level).toBe('grace');
expect(w.emphasis).toBe('12 more days');
expect(w.message).toBe(
'This installation is not licensed. Everything keeps working for 12 more days.'
);
expect(`${w.lead}${w.emphasis}${w.tail}`).toBe(w.message);
});
it('builds the blocked message', () => {
const w = getLicenseWarning(BLOCKED);
expect(w.level).toBe('blocked');
expect(w.message).toBe('This installation is not licensed. Only 1 station can measure.');
expect(`${w.lead}${w.emphasis}${w.tail}`).toBe(w.message);
});
});
describe('NavRail licence state', () => {
let container, root;
const renderRail = (user, status) => {
mocks.user = user;
mocks.status = status;
act(() => root.render(<MemoryRouter><NavRail /></MemoryRouter>));
};
const licenceItems = () => [...container.querySelectorAll('.nav-rail-item')]
.filter(b => b.textContent.includes('Licence'));
const popover = () => container.querySelector('.nav-rail-license-popover');
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('shows nothing extra when the licence is valid', () => {
renderRail(ADMIN, VALID);
expect(container.querySelector('.nav-rail-item--license')).toBeNull();
expect(container.querySelector('.nav-rail-badge')).toBeNull();
expect(licenceItems()).toHaveLength(1);
renderRail(OPERATOR, VALID);
expect(licenceItems()).toHaveLength(0);
});
it('puts the admin Licence item into the grace state', () => {
renderRail(ADMIN, GRACE);
const items = licenceItems();
expect(items).toHaveLength(1);
const [item] = items;
expect(item.classList).toContain('nav-rail-item--license-grace');
expect(item.querySelector('.nav-rail-badge')).not.toBeNull();
expect(item.textContent).toContain('🔑');
expect(item.getAttribute('aria-label')).toBe(`Licence: ${getLicenseWarning(GRACE).message}`);
expect(container.querySelector('.nav-rail-bottom .nav-rail-item--license')).toBeNull();
});
it('opens the admin popover on focus, with the bold day count and a page link', () => {
renderRail(ADMIN, GRACE);
const [item] = licenceItems();
act(() => item.focus());
const pop = popover();
expect(pop).not.toBeNull();
expect(pop.getAttribute('role')).toBe('dialog');
expect(pop.querySelector('strong').textContent).toBe('12 more days');
expect(pop.textContent).toContain(getLicenseWarning(GRACE).message);
expect(pop.querySelector('button').textContent).toBe('Open licence page');
expect(item.getAttribute('aria-describedby')).toBe(pop.id);
act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })));
expect(popover()).toBeNull();
});
it('marks the admin item as blocked', () => {
renderRail(ADMIN, BLOCKED);
const [item] = licenceItems();
expect(item.classList).toContain('nav-rail-item--license-blocked');
expect(item.querySelector('.nav-rail-badge')).not.toBeNull();
});
it('gives a non-admin a "!" marker at the bottom, without a page link', () => {
renderRail(OPERATOR, BLOCKED);
const items = licenceItems();
expect(items).toHaveLength(1);
const [item] = items;
expect(item.closest('.nav-rail-bottom')).not.toBeNull();
expect(item.classList).toContain('nav-rail-item--license-blocked');
expect(item.querySelector('.nav-rail-icon').textContent).toBe('!');
act(() => item.click());
const pop = popover();
expect(pop.getAttribute('role')).toBe('tooltip');
expect(pop.textContent).toBe(getLicenseWarning(BLOCKED).message);
expect(pop.querySelector('button')).toBeNull();
act(() => document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })));
expect(popover()).toBeNull();
});
it('uses the grace state for a non-admin in grace', () => {
renderRail(OPERATOR, GRACE);
const [item] = licenceItems();
expect(item.classList).toContain('nav-rail-item--license-grace');
});
it('opens on hover only after the delay, and closes after leaving', () => {
vi.useFakeTimers();
try {
renderRail(OPERATOR, GRACE);
const anchor = licenceItems()[0].closest('.nav-rail-anchor');
act(() => anchor.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
act(() => vi.advanceTimersByTime(100));
expect(popover()).toBeNull();
act(() => vi.advanceTimersByTime(150));
expect(popover()).not.toBeNull();
act(() => anchor.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body })));
expect(popover()).not.toBeNull();
act(() => vi.advanceTimersByTime(300));
expect(popover()).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,136 @@
import { act } from 'react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createRoot } from 'react-dom/client';
import { LIST_STAGING, PendingMarker, RevealPlate, REHOLD_PX, shouldHoldList } from '../VehicleListStaging';
global.IS_REACT_ACT_ENVIRONMENT = true;
describe('VehicleListStaging', () => {
let container, root;
const render = el => act(() => root.render(el));
beforeEach(() => {
container = document.createElement('div');
root = createRoot(container);
});
afterEach(() => act(() => root.unmount()));
it('morphs the marker between frames of one shape, lasting distortMs', () => {
render(<PendingMarker reduceMotion={false} />);
const anim = container.querySelector('animate');
const frames = anim.getAttribute('values').split(';');
const commands = d => d.replace(/[^A-Za-z]/g, '');
const numbers = d => d.match(/-?\d+(\.\d+)?/g).map(Number);
expect(new Set(frames.map(commands)).size).toBe(1);
expect(new Set(frames.map(f => numbers(f).length)).size).toBe(1);
// Every point stays inside the 12px-high viewBox.
for (const f of frames) {
numbers(f).filter((_, i) => i % 2 === 1).forEach(y => {
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(12);
});
}
expect(anim.getAttribute('keyTimes').split(';')).toHaveLength(frames.length);
expect(anim.getAttribute('dur')).toBe(`${LIST_STAGING.distortMs}ms`);
});
it('starts the distortion only when told to, not at a time after mount', () => {
render(<PendingMarker reduceMotion={false} />);
const marker = container.querySelector('.vehicle-list-marker');
const anim = container.querySelector('animate');
expect(anim.getAttribute('begin')).toBe('indefinite');
expect(marker.classList.contains('vehicle-list-marker--distorting')).toBe(false);
// jsdom has no SMIL; stand in for SVGAnimationElement.beginElement.
anim.beginElement = vi.fn();
render(<PendingMarker reduceMotion={false} />);
expect(anim.beginElement).not.toHaveBeenCalled();
render(<PendingMarker reduceMotion={false} distorting />);
expect(container.querySelector('animate')).toBe(anim); // same element, not remounted
expect(anim.beginElement).toHaveBeenCalledTimes(1);
expect(marker.classList.contains('vehicle-list-marker--distorting')).toBe(true);
render(<PendingMarker reduceMotion={false} distorting />);
expect(anim.beginElement).toHaveBeenCalledTimes(1);
});
it('keeps the marker static when motion is reduced', () => {
render(<PendingMarker reduceMotion />);
expect(container.querySelector('path')).not.toBeNull();
expect(container.querySelector('animate')).toBeNull();
render(<PendingMarker reduceMotion distorting />); // no SMIL to start
expect(container.querySelector('animate')).toBeNull();
});
it('splits the plate into hidden, staggered chars under one label', () => {
render(<RevealPlate text="CA 1234 AB" />);
const plate = container.querySelector('.vehicle-list-number');
const chars = [...plate.querySelectorAll('.vehicle-list-char')];
expect(plate.getAttribute('aria-label')).toBe('CA 1234 AB');
expect(chars.map(c => c.textContent).join('')).toBe('CA 1234 AB');
expect(chars.every(c => c.getAttribute('aria-hidden') === 'true')).toBe(true);
const delays = chars.map(c => parseInt(c.style.animationDelay, 10));
delays.slice(1).forEach((d, i) => expect(d).toBeGreaterThan(delays[i]));
// The last char finishes within the entering phase (30% per char).
expect(delays.at(-1) + LIST_STAGING.enterMs * 0.3).toBeLessThanOrEqual(LIST_STAGING.enterMs);
});
it('fits a long plate into the entering phase too', () => {
render(<RevealPlate text="ABCDEFGHIJKLMNOPQRST" />);
const chars = [...container.querySelectorAll('.vehicle-list-char')];
const last = parseInt(chars.at(-1).style.animationDelay, 10);
expect(last + LIST_STAGING.enterMs * 0.3).toBeLessThanOrEqual(LIST_STAGING.enterMs);
});
describe('shouldHoldList', () => {
const move = (clientX, clientY, pointerType = 'mouse') => ({ pointerType, clientX, clientY });
it('holds for a mouse or pen when no click released the hold', () => {
expect(shouldHoldList(move(10, 10), null)).toBe(true);
expect(shouldHoldList(move(10, 10, 'pen'), null)).toBe(true);
});
it('never holds for touch', () => {
expect(shouldHoldList(move(10, 10, 'touch'), null)).toBe(false);
expect(shouldHoldList(move(100, 100, 'touch'), { x: 0, y: 0 })).toBe(false);
});
it('after a click, holds again only REHOLD_PX away from the click point', () => {
const at = { x: 50, y: 50 };
expect(REHOLD_PX).toBe(4);
expect(shouldHoldList(move(50, 50), at)).toBe(false);
expect(shouldHoldList(move(53, 50), at)).toBe(false);
expect(shouldHoldList(move(52, 52), at)).toBe(false); // ~2.83px
expect(shouldHoldList(move(54, 50), at)).toBe(true);
expect(shouldHoldList(move(50, 46), at)).toBe(true);
expect(shouldHoldList(move(53, 53), at)).toBe(true); // ~4.24px
});
});
describe('overlay marker CSS', () => {
// Vitest stubs CSS imports (even ?raw), so read the file itself.
const mainCss = readFileSync(resolve(__dirname, '../Main.css'), 'utf8');
// The body of the top-level rule whose selector is exactly `sel`.
const rule = sel => {
const start = mainCss.indexOf(`\n${sel} {`);
return start < 0 ? '' : mainCss.slice(start, mainCss.indexOf('}', start));
};
it('gives the marker no layout height and draws its 12px line over the row border', () => {
expect(rule('.vehicle-list-marker')).toMatch(/height: 0;/);
expect(rule('.vehicle-list-marker')).toMatch(/pointer-events: none;/);
expect(rule('.vehicle-list-marker')).toMatch(/z-index: \d+;/);
const line = rule('.vehicle-list-marker-line');
expect(line).toMatch(/position: absolute;/);
expect(line).toMatch(/top: -6px;/);
expect(line).toMatch(/height: 12px;/);
});
it('opens the new row from height 0', () => {
const open = mainCss.match(/@keyframes vehicle-list-open \{\s*0% \{([^}]*)\}/);
expect(open[1]).toMatch(/height: 0;/);
});
});
});
@@ -0,0 +1,119 @@
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap');
:root{
--bg:#02070c;--surface:#06131d;--surface2:#081b28;--surface3:#0a2230;
--cyan:#12dcff;--cyan2:#007fa8;--blue:#087dff;--green:#00f39c;
--orange:#ff6426;--text:#e4f9ff;--muted:#638b9d;--dim:#345a6b;
--line:#0c526a;--line2:#123749;
}
*{box-sizing:border-box}
body{margin:0;background:#02070c;color:var(--text);font-family:Rajdhani,"Segoe UI",sans-serif}
button,input{font:inherit}
button{cursor:pointer}
.app{
min-height:100vh;display:grid;grid-template-columns:252px 1fr;grid-template-rows:78px 1fr;
background:
radial-gradient(ellipse at 68% 9%,rgba(0,180,255,.11),transparent 28%),
radial-gradient(ellipse at 80% 80%,rgba(0,60,120,.12),transparent 32%),
linear-gradient(120deg,#02070c,#03111b 48%,#02070c);
overflow:hidden;
}
.app:before{
content:"";position:fixed;inset:0;pointer-events:none;z-index:20;opacity:.045;
background:repeating-linear-gradient(0deg,transparent 0 3px,#fff 4px);
mix-blend-mode:screen;
}
/* TOP */
.topbar{
grid-column:1/-1;height:78px;display:flex;align-items:center;padding:0 25px;
border-bottom:1px solid #0b4052;background:rgba(3,12,19,.94);backdrop-filter:blur(14px);
box-shadow:0 10px 45px rgba(0,0,0,.35);position:relative;z-index:10;
}
.topbar:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);box-shadow:0 0 12px var(--cyan);opacity:.45}
.brand{width:248px;display:flex;align-items:center;gap:12px}
.brand-emblem{width:43px;height:43px;display:grid;place-items:center;color:var(--cyan);border:1px solid #08718a;background:linear-gradient(145deg,rgba(0,221,255,.13),rgba(0,0,0,.15));border-radius:9px;box-shadow:0 0 25px rgba(0,217,255,.14),inset 0 0 18px rgba(0,217,255,.08)}
.brand-name{font-weight:700;font-size:20px;letter-spacing:.08em;line-height:1}
.brand-caption{font-size:8px;color:var(--cyan);letter-spacing:.17em;margin-top:5px}
.main-nav{display:flex;gap:8px}
.top-nav{width:65px;height:50px;color:#5d8596;border:1px solid #133e50;background:linear-gradient(180deg,#071923,#04111a);border-radius:8px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;box-shadow:inset 0 0 15px rgba(0,150,200,.025);transition:.2s}
.top-nav span{font-size:7px;font-weight:700;letter-spacing:.1em}
.top-nav.active,.top-nav:hover{color:var(--cyan);border-color:var(--cyan);background:linear-gradient(180deg,#082633,#04141e);box-shadow:0 0 22px rgba(0,217,255,.17),inset 0 0 20px rgba(0,217,255,.09)}
.system-status{margin-left:auto;display:flex;gap:9px;padding-right:21px;border-right:1px solid #163b4b;align-items:flex-start}
.system-status strong{display:block;color:var(--cyan);font-size:9px;letter-spacing:.1em}
.system-status small{display:block;color:#5c8292;font-size:8px}
.pulse{width:7px;height:7px;margin-top:3px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px rgba(0,243,156,.06),0 0 13px var(--green);animation:pulse 1.8s infinite}
@keyframes pulse{50%{box-shadow:0 0 0 8px rgba(0,243,156,0),0 0 20px var(--green)}}
.user{display:flex;align-items:center;gap:9px;margin-left:20px}
.avatar{width:35px;height:35px;border-radius:50%;display:grid;place-items:center;color:#001018;font-weight:700;background:linear-gradient(135deg,#48ecff,#087fff);box-shadow:0 0 22px rgba(0,180,255,.25)}
.user b{font-size:10px;display:block}.user small{font-size:8px;color:#638999;display:block}
.user button{margin-left:11px;background:rgba(5,19,28,.8);border:1px solid #255266;color:#86aab7;padding:7px 12px;font-size:9px;border-radius:5px}
/* SIDEBAR */
.sidebar{position:relative;padding:20px 16px;border-right:1px solid #0a4053;background:linear-gradient(180deg,rgba(5,19,29,.97),rgba(2,9,14,.98));overflow:hidden}
.sidebar:after{content:"";position:absolute;right:-45px;top:220px;width:110px;height:340px;background:repeating-linear-gradient(90deg,transparent 0 9px,#0a2b39 10px 11px);opacity:.18;transform:skewY(-25deg)}
.new-vehicle{width:100%;height:42px;border:1px solid #ff8658;border-radius:6px;color:#160800;font-weight:800;letter-spacing:.08em;background:linear-gradient(180deg,#ff7b35,#ff5821);box-shadow:0 0 22px rgba(255,96,30,.22),inset 0 1px rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;gap:7px}
.search{height:36px;margin:12px 0;display:flex;align-items:center;gap:8px;padding:0 10px;border:1px solid #123e50;border-radius:6px;background:#03101a;box-shadow:inset 0 0 14px rgba(0,0,0,.4)}
.search input{width:100%;border:0;outline:0;background:none;color:#c5e5ee;font-size:10px}.search input::placeholder{color:#426d7e}
.vehicle-list{display:grid;gap:3px}
.vehicle{height:45px;width:100%;border:1px solid transparent;border-left:2px solid transparent;border-radius:5px;background:transparent;color:#7093a1;display:flex;align-items:center;gap:7px;padding:0 9px;text-align:left;position:relative}
.vehicle:hover{color:#bcecf6;background:rgba(0,190,240,.035)}
.vehicle.selected{color:var(--cyan);border-color:#09647c;border-left-color:var(--cyan);background:linear-gradient(90deg,rgba(0,217,255,.13),rgba(0,217,255,.015));box-shadow:0 0 20px rgba(0,217,255,.06),inset 0 0 20px rgba(0,217,255,.05)}
.vehicle-dot{width:5px;height:5px;border-radius:50%;background:#2a4652}.vehicle-dot.live{background:var(--green);box-shadow:0 0 8px var(--green)}
.vehicle b{font-size:11px}.vehicle small{margin-left:auto;color:#4e8aa0;font-size:9px}
.sidebar-label{position:absolute;bottom:20px;left:18px;color:#4d7a8b;font-size:8px;letter-spacing:.18em;display:flex;gap:8px;align-items:center}.sidebar-label span{color:var(--cyan);font-size:15px}
/* CONTENT */
.content{min-width:0;min-height:0;padding:18px 24px 12px;overflow:auto;background:radial-gradient(ellipse at 70% 0,rgba(0,170,255,.07),transparent 27%)}
.vehicle-hero{height:106px;border:1px solid #0a5167;border-radius:10px;position:relative;overflow:hidden;background:linear-gradient(90deg,#06131d 0,#06131d 42%,rgba(6,19,29,.7) 65%,#06131d);box-shadow:0 15px 35px rgba(0,0,0,.18),inset 0 0 35px rgba(0,150,210,.035)}
.vehicle-hero:after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 40%,rgba(0,0,0,.25))}
.vehicle-heading{position:relative;z-index:4;height:100%;display:flex;align-items:center;padding-left:17px;gap:10px}
.vehicle-icon{width:52px;height:52px;display:grid;place-items:center;color:var(--cyan);border:1px solid #0b728a;border-radius:9px;background:rgba(0,217,255,.07);box-shadow:0 0 25px rgba(0,217,255,.1),inset 0 0 15px rgba(0,217,255,.07)}
.vehicle-heading h1{margin:0;font:700 31px "Barlow Condensed",sans-serif;letter-spacing:.04em}.crumb{font-size:8px;color:#527b8c;letter-spacing:.13em;margin-top:2px}.crumb i{color:var(--cyan);font-style:normal;margin:0 5px}
.status{font-size:8px;padding:5px 8px;border:1px solid;border-radius:20px;letter-spacing:.08em}.status.active{color:var(--green);border-color:#08765a;background:rgba(0,243,156,.05);box-shadow:0 0 12px rgba(0,243,156,.06)}.status.scale{color:#ffad59;border-color:#76502d;background:rgba(255,100,38,.05)}
/* HERO ART */
.industrial-hero{position:absolute;inset:0 0 0 39%;overflow:hidden}
/* The photograph, cropped to the hero strip. `62% 42%` holds the truck cab in
frame as the panel width changes; `center` drifts to the empty loading bay. */
.hero-photo{position:absolute;inset:0;background:url('../../assets/weigh-station-hero.jpg') right center/cover no-repeat;
/* Pushed toward the panel's palette so the photo belongs to the UI rather
than sitting in it: slightly desaturated, cooled, and darkened at source
so the overlays below read as light rather than fighting the image. */
filter:saturate(1.02) contrast(1.08) brightness(.88) hue-rotate(-4deg)}
/* Fades the photo out to the left so it slides under the vehicle title instead
of ending on a hard vertical seam. */
.hero-photo:after{content:"";position:absolute;inset:0;
background:linear-gradient(90deg,#061019 0%,rgba(6,16,25,.82) 12%,rgba(6,16,25,.28) 34%,transparent 58%)}
.hero-vignette{position:absolute;inset:0;background:linear-gradient(90deg,#06131d 0%,rgba(5,17,26,.58) 17%,rgba(5,17,26,.05) 62%,rgba(2,8,12,.25));z-index:3}
.hero-scan{position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,217,255,.07),transparent);transform:translateX(-100%);animation:scan 7s linear infinite;z-index:4}@keyframes scan{to{transform:translateX(180%)}}
.hero-haze{position:absolute;border-radius:50%;filter:blur(28px);opacity:.35}.haze-a{width:250px;height:90px;right:80px;top:15px;background:#087bff}.haze-b{width:180px;height:80px;right:300px;bottom:0;background:#ff5b24;opacity:.15}
.hero-neon{position:absolute;height:2px;background:var(--cyan);box-shadow:0 0 9px var(--cyan),0 0 22px rgba(0,217,255,.45)}.neon-a{right:210px;top:23px;width:125px}.neon-b{right:52px;top:43px;width:80px;background:var(--orange);box-shadow:0 0 10px var(--orange)}.neon-c{right:360px;bottom:28px;width:90px}
/* METRICS */
.metric-row{display:grid;grid-template-columns:1.28fr 1fr 1fr 1fr 1.05fr;gap:10px;margin-top:12px}
.metric-card{height:99px;position:relative;overflow:hidden;border:1px solid #10536a;border-radius:9px;background:linear-gradient(145deg,#071a25,#04131d);padding:12px 14px;box-shadow:0 10px 24px rgba(0,0,0,.16),inset 0 0 22px rgba(0,150,200,.025)}
.metric-card:before{content:"";position:absolute;left:0;top:0;width:34%;height:1px;background:#1e839b;box-shadow:0 0 10px #1e839b}
.metric-card.current{border-color:#08b9dc;box-shadow:0 0 25px rgba(0,217,255,.09),inset 0 0 25px rgba(0,217,255,.07)}.metric-card.current:before{width:65%;background:var(--cyan);box-shadow:0 0 13px var(--cyan)}
.metric-card.net{border-color:#087c62;background:linear-gradient(145deg,#061d1b,#041411)}.metric-card.net:before{background:var(--green);box-shadow:0 0 10px var(--green)}
.metric-glow{position:absolute;width:110px;height:110px;right:-45px;bottom:-55px;border-radius:50%;border:1px solid rgba(0,217,255,.08);box-shadow:0 0 30px rgba(0,217,255,.03)}
.metric-label{display:flex;align-items:center;gap:7px;color:#7298a7;font-size:8px;font-weight:600;letter-spacing:.12em}.metric-icon{width:25px;height:25px;border:1px solid #17647a;border-radius:6px;display:grid;place-items:center;color:var(--cyan);background:rgba(0,217,255,.055)}.net .metric-icon{color:var(--green);border-color:#0b705a}
.metric-value{font:700 25px "Barlow Condensed",sans-serif;letter-spacing:.035em;margin-top:4px;color:#e8fbff}.metric-card.big .metric-value{font-size:34px;color:var(--cyan);text-shadow:0 0 17px rgba(0,217,255,.25)}.metric-card.net .metric-value{color:var(--green);text-shadow:0 0 15px rgba(0,243,156,.15)}
.metric-time{font-size:7px;color:#4d7484;margin-top:1px}.metric-time b{color:#668d9b}
.action-stack{display:flex;flex-direction:column;gap:8px}.print,.design{height:45px;border-radius:7px;display:flex;align-items:center;gap:8px;padding:0 14px;font-weight:700;font-size:10px;letter-spacing:.06em}.print{color:#160700;background:linear-gradient(180deg,#ff7132,#ff5820);border:1px solid #ff8a5d;box-shadow:0 0 18px rgba(255,95,30,.16)}.design{color:var(--cyan);background:#061923;border:1px solid #14627a;box-shadow:inset 0 0 15px rgba(0,217,255,.04)}.design svg:last-child{margin-left:auto}
/* DATA */
.data-card{margin-top:12px;border:1px solid #0c546a;border-radius:10px;background:linear-gradient(180deg,rgba(5,21,31,.98),rgba(3,13,20,.98));box-shadow:0 20px 45px rgba(0,0,0,.18),inset 0 0 35px rgba(0,150,210,.025);overflow:hidden;position:relative}
.data-card:before{content:"";position:absolute;left:0;top:0;width:190px;height:2px;background:var(--cyan);box-shadow:0 0 12px var(--cyan)}
.card-header{height:49px;padding:0 17px;border-bottom:1px solid #0d3c4d;display:flex;align-items:center;justify-content:space-between}.card-header>div{display:flex;align-items:center;gap:9px;color:var(--cyan)}.card-header b{font-size:13px;color:#bdeaf3}.card-header>span{font-size:7px;color:#315f70;letter-spacing:.2em}
.history-header{height:38px;padding:0 17px;display:flex;align-items:center;justify-content:space-between}.history-header div{display:flex;gap:7px;align-items:center;color:var(--cyan);font-size:8px;font-weight:600;letter-spacing:.14em}.history-header button{border:0;background:none;color:#4e8394;font-size:8px;letter-spacing:.12em}.history-header button span{color:var(--cyan)}
.table-shell{margin:0 17px;border:1px solid #0d3d4d;border-radius:5px;overflow:hidden}
table{width:100%;border-collapse:collapse;min-width:760px}th{text-align:left;padding:8px;color:#4e8394;font-size:7px;letter-spacing:.1em;background:linear-gradient(180deg,#071d29,#061722);border-right:1px solid #0d3544}td{padding:8px;color:#8bb1bd;font-size:9px;border-top:1px solid #0c3040;border-right:1px solid #0b2d3b;background:rgba(2,12,18,.65)}tbody tr:hover td{background:rgba(0,217,255,.045);color:#c8eff6}.remove{width:20px;height:20px;border:1px solid #275565;background:#05131b;color:#7197a4;border-radius:4px}
.form{padding:15px 17px 5px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 15px}.field>span{display:block;color:#568597;font-size:7px;letter-spacing:.13em;margin-bottom:5px}.control{height:35px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;border:1px solid #174c5f;border-radius:5px;background:linear-gradient(180deg,#061721,#031018);color:#a2c5ce;font-size:9px;box-shadow:inset 0 0 12px rgba(0,0,0,.3)}.control:hover{border-color:#1491ae;box-shadow:0 0 12px rgba(0,217,255,.05),inset 0 0 12px rgba(0,0,0,.3)}
.bottom-actions{display:flex;align-items:center;gap:15px;padding:10px 17px 15px}.save{height:38px;padding:0 16px;display:flex;align-items:center;gap:8px;color:#001219;font-weight:800;font-size:9px;letter-spacing:.05em;border:1px solid #6af0ff;border-radius:5px;background:linear-gradient(180deg,#23e6ff,#06bede);box-shadow:0 0 22px rgba(0,217,255,.18)}.ready{font-size:7px;color:#4d7f8e;letter-spacing:.16em}.ready span{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);margin-right:7px}
footer{display:flex;justify-content:space-between;padding:8px 2px;color:#284b5a;font-size:7px;letter-spacing:.18em}
/* RESPONSIVE */
@media(max-width:1250px){.brand{width:205px}.system-status{display:none}.metric-row{grid-template-columns:1.2fr 1fr 1fr 1fr}.action-stack{grid-column:span 4;flex-direction:row}.action-stack>*{flex:1}}
@media(max-width:900px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}.topbar{flex-wrap:wrap;height:auto;min-height:78px;padding:10px 15px}.main-nav{order:3;width:100%;overflow:auto;padding-bottom:4px}.sidebar{border-right:0;border-bottom:1px solid #0a4053}.vehicle-list{display:flex;overflow:auto}.vehicle{min-width:150px}.sidebar-label{display:none}.content{padding:12px}.industrial-hero{inset:0;opacity:.3}.metric-row{grid-template-columns:1fr 1fr}.action-stack{grid-column:span 2}.form{grid-template-columns:1fr 1fr}}
@media(max-width:560px){.user>div:nth-child(2),.user button{display:none}.metric-row,.form{grid-template-columns:1fr}.action-stack{grid-column:auto;flex-direction:column}.status{display:none}.brand-caption{display:none}}
@@ -0,0 +1,215 @@
import React from "react";
import "./ScalesAppCyberpunk.css";
const trucks = [
{ plate: "B7898TP", weight: "41805 kg", live: true },
{ plate: "B8710CB", weight: "15350 kg", live: false },
{ plate: "B8768TP", weight: "44490 kg", live: true },
{ plate: "dddd", weight: "—", live: false },
{ plate: "Kiro", weight: "36400 kg", live: true },
{ plate: "Krasi", weight: "—", live: false },
{ plate: "KOKO", weight: "17000 kg", live: false },
];
const history = [
["warehouse", "Товародател 1", "Спедитор 1", "ff", "d1"],
["cargo 1", "ship", "Спедитор 1", "ff", "d1"],
["cargo 1", "—", "Спедитор 1", "—", "—"],
["cargo 1", "Товародател 1", "Спедитор 1", "ff", "d1"],
];
function Icon({ type, size = 18 }) {
const p = {
width: size, height: size, viewBox: "0 0 24 24",
fill: "none", stroke: "currentColor", strokeWidth: 1.7,
strokeLinecap: "round", strokeLinejoin: "round"
};
const d = {
truck: <><path d="M3 6h11v11H3z"/><path d="M14 10h4l3 3v4h-7z"/><circle cx="7" cy="19" r="2"/><circle cx="18" cy="19" r="2"/></>,
dashboard: <><path d="M4 13a8 8 0 1 1 16 0"/><path d="M12 13l4-5"/><path d="M7 17h10"/></>,
file: <><path d="M6 3h9l4 4v14H6z"/><path d="M15 3v5h5"/><path d="M9 12h6M9 16h6"/></>,
users: <><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 20c.7-4 3-6 6-6s5.3 2 6 6"/><path d="M14 14c3-.2 5 1.8 6 5"/></>,
gear: <><circle cx="12" cy="12" r="3"/><path d="M19 15l2 1-2 3-2-1a8 8 0 0 1-2 1l-.2 2h-3.6l-.2-2a8 8 0 0 1-2-1l-2 1-2-3 2-1a8 8 0 0 1 0-3l-2-1 2-3 2 1a8 8 0 0 1 2-1l.2-2h3.6l.2 2a8 8 0 0 1 2 1l2-1 2 3-2 1a8 8 0 0 1 0 3z"/></>,
chart: <><path d="M4 19V9M10 19V5M16 19v-8M22 19V3"/></>,
scale: <><path d="M5 5h14M12 5v15M7 20h10"/><path d="m5 8-3 6h6zM19 8l-3 6h6z"/></>,
print: <><path d="M6 9V3h12v6"/><path d="M5 18H3a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h18a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 14h12v7H6z"/></>,
edit: <><path d="m4 20 4-1 10-10-3-3L5 16z"/><path d="m14 7 3 3"/></>,
search: <><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></>,
save: <><path d="M5 3h12l2 2v16H5z"/><path d="M8 3v6h8V3M8 21v-7h8v7"/></>,
database: <><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v7c0 2 3.6 3 8 3s8-1 8-3V5"/><path d="M4 12v7c0 2 3.6 3 8 3s8-1 8-3v-7"/></>,
chevron: <path d="m7 10 5 5 5-5"/>,
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
check: <path d="m5 12 4 4L19 6"/>,
plus: <><path d="M12 5v14M5 12h14"/></>,
};
return <svg {...p}>{d[type]}</svg>;
}
function Brand() {
return (
<div className="brand">
<div className="brand-emblem"><Icon type="scale" size={27}/></div>
<div>
<div className="brand-name">SCALESAPP</div>
<div className="brand-caption">REAL-TIME DATA MONITOR</div>
</div>
</div>
);
}
function TopNav({ icon, label, active }) {
return <button className={`top-nav ${active ? "active" : ""}`}>
<Icon type={icon} size={19}/><span>{label}</span>
</button>;
}
function Metric({ label, value, tone = "", big = false }) {
return (
<div className={`metric-card ${tone} ${big ? "big" : ""}`}>
<div className="metric-glow"/>
<div className="metric-label"><span className="metric-icon"><Icon type="scale" size={15}/></span>{label}</div>
<div className="metric-value">{value}</div>
<div className="metric-time">2/6/2026 01:18 AM <b>(admin)</b></div>
</div>
);
}
function Select({ label, value, className = "" }) {
return (
<label className={`field ${className}`}>
<span>{label}</span>
<div className="control">
<span>{value}</span><Icon type="chevron" size={13}/>
</div>
</label>
);
}
function IndustrialHero() {
return (
<div className="industrial-hero">
{/* The photograph carries the scene; the layers over it (vignette, scan,
haze, neon) are what tie it to the rest of the UI rather than leaving
it looking like a stock image dropped into a panel. */}
<div className="hero-photo"/>
<div className="hero-vignette"/>
<div className="hero-scan"/>
<div className="hero-haze haze-a"/>
<div className="hero-haze haze-b"/>
<div className="hero-neon neon-a"/>
<div className="hero-neon neon-b"/>
<div className="hero-neon neon-c"/>
</div>
);
}
export default function ScalesAppCyberpunk() {
return (
<div className="app">
<header className="topbar">
<Brand/>
<nav className="main-nav">
<TopNav icon="truck" label="VEHICLES" active/>
<TopNav icon="file" label="REPORTS"/>
<TopNav icon="users" label="USERS"/>
<TopNav icon="gear" label="SETTINGS"/>
<TopNav icon="chart" label="ANALYTICS"/>
</nav>
<div className="system-status">
<span className="pulse"/>
<div><strong>SYSTEM ONLINE</strong><small>Everything keeps working for 14 more days.</small></div>
</div>
<div className="user">
<div className="avatar">AD</div>
<div><b>admin</b><small>(employee)</small></div>
<button>Logout</button>
</div>
</header>
<aside className="sidebar">
<button className="new-vehicle"><Icon type="plus" size={16}/> NEW VEHICLE</button>
<div className="search"><Icon type="search" size={15}/><input placeholder="Search vehicle..."/></div>
<div className="vehicle-list">
{trucks.map((t, i) => (
<button className={`vehicle ${i === 0 ? "selected" : ""}`} key={t.plate}>
<Icon type="truck" size={17}/>
<span className={`vehicle-dot ${t.live ? "live" : ""}`}/>
<b>{t.plate}</b><small>{t.weight}</small>
</button>
))}
</div>
<div className="sidebar-label"><span>◈</span> WEIGH STATION</div>
</aside>
<main className="content">
<section className="vehicle-hero">
<div className="vehicle-heading">
<div className="vehicle-icon"><Icon type="truck" size={28}/></div>
<div>
<h1>B7898TP</h1>
<div className="crumb">SCALES <i>›</i> LIVE WEIGHING</div>
</div>
<span className="status active">● ACTIVE</span>
<span className="status scale">◉ ON SCALE</span>
</div>
<IndustrialHero/>
</section>
<section className="metric-row">
<Metric label="CURRENT READING" value="3200 kg" tone="current" big/>
<Metric label="TARE" value="41805 kg"/>
<Metric label="GROSS" value="27735 kg"/>
<Metric label="NET" value="-14070 kg" tone="net"/>
<div className="action-stack">
<button className="print"><Icon type="print" size={17}/> PRINT NOTICE</button>
<button className="design"><Icon type="edit" size={16}/> DESIGN NOTICE <Icon type="chevron" size={13}/></button>
</div>
</section>
<section className="data-card">
<div className="card-header">
<div><Icon type="database" size={18}/><b>Additional Data</b></div>
<span>DATA // 07</span>
</div>
<div className="history-header">
<div><Icon type="file" size={14}/> PREVIOUSLY USED</div>
<button>VIEW ALL <span>→</span></button>
</div>
<div className="table-shell">
<table>
<thead><tr>
<th>ТОВАР</th><th>ИДВА ОТ</th><th>ТОВАРОДАТЕЛ</th>
<th>СПЕДИТОР</th><th>АГЕНТ</th><th>КОРАБОСОБСТВЕНИК</th><th/>
</tr></thead>
<tbody>
{history.map((r, i) => <tr key={i}>
{r.map((v,j)=><td key={j}>{v}</td>)}
<td><button className="remove">×</button></td>
</tr>)}
</tbody>
</table>
</div>
<div className="form">
<Select label="ТОВАР" value="ship"/>
<Select label="ИДВА ОТ" value="ship"/>
<Select label="ТОВАРОДАТЕЛ" value="cargo 1"/>
<Select label="АГЕНТ" value="ff"/>
<Select label="СПЕДИТОР" value="Спедитор 1"/>
<Select label="КОРАБОСОБСТВЕНИК" value="d1"/>
<label className="field"><span>№ КОНТЕЙНЕР</span><div className="control">KKKK111113</div></label>
</div>
<div className="bottom-actions">
<button className="save"><Icon type="save" size={15}/> SAVE EXTRA DATA</button>
<div className="ready"><span/> READY FOR NEXT WEIGH</div>
</div>
</section>
<footer><span>LOAD // WEIGH // REPEAT</span><span>SCALESAPP // CONTROL TERMINAL</span></footer>
</main>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
// What the rail shows for a licence status from useLicense(): null when there
// is nothing to warn about, otherwise the level and the message split around
// the part shown in bold.
export function getLicenseWarning(status) {
if (!status || status.valid) return null;
const lead = 'This installation is not licensed. ';
if (status.in_grace) {
const emphasis = `${status.grace_days_left} more days`;
return {
level: 'grace',
lead: `${lead}Everything keeps working for `,
emphasis,
tail: '.',
message: `${lead}Everything keeps working for ${emphasis}.`,
};
}
const text = `${lead}Only ${status.stations} station can measure.`;
return { level: 'blocked', lead: text, emphasis: '', tail: '', message: text };
}
+37 -31
View File
@@ -6,7 +6,7 @@ const NomenclatureContext = createContext();
// Centralized nomenclature configuration
const NOMENCLATURES_CONFIG = [
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'name', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'vehicle_number', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
// { key: 'vesselOwners', jsonKey: 'vessel_owners', sortBy: 'name', endpoint: '/vessel_owners/api/list/', sseObjectType: 'vessel_owner' },
// { key: 'agents', jsonKey: 'agents', sortBy: 'name', endpoint: '/agents/api/list/', sseObjectType: 'agent' },
// { key: 'objects', jsonKey: 'objects', sortBy: 'id', endpoint: '/objects/api/list/', sseObjectType: 'object' },
@@ -42,21 +42,25 @@ export const useNomenclatures = () => {
return context;
};
// Compare two items by the configured sort field. 'id' compares numerically;
// any other field compares as text, case-insensitive with natural number
// order (so "CA 2" sorts before "CA 10"). Missing values sort as empty text.
const compareBy = (a, b, orderBy) => {
if (orderBy === 'id') {
return (a.id || 0) - (b.id || 0);
}
return String(a[orderBy] ?? '').localeCompare(String(b[orderBy] ?? ''), undefined, {
numeric: true,
sensitivity: 'base',
});
};
// Utility function to sort nomenclature array based on configuration
const sortNomenclature = (array, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
return [...array].sort((a, b) => {
if (orderBy === 'id') {
return (a.id || 0) - (b.id || 0);
} else {
// Order by name (case-insensitive)
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
}
});
return [...array].sort((a, b) => compareBy(a, b, orderBy));
};
// Utility function to insert item in sorted position
@@ -65,23 +69,9 @@ const insertSorted = (array, newItem, nomenclatureKey) => {
const orderBy = config?.sortBy || 'name';
const newArray = [...array];
// Find the correct insertion position
let insertIndex = newArray.length;
if (orderBy === 'id') {
insertIndex = newArray.findIndex(item => {
const comparison = item.id > newItem.id;
return comparison;
});
if (insertIndex === -1) insertIndex = newArray.length;
} else {
const newName = (newItem.name || '').toLowerCase();
insertIndex = newArray.findIndex(item =>
(item.name || '').toLowerCase() > newName
);
if (insertIndex === -1) insertIndex = newArray.length;
}
// Insert before the first item that sorts after the new one
let insertIndex = newArray.findIndex(item => compareBy(item, newItem, orderBy) > 0);
if (insertIndex === -1) insertIndex = newArray.length;
newArray.splice(insertIndex, 0, newItem);
return newArray;
@@ -102,9 +92,7 @@ const updateSorted = (array, updatedItem, nomenclatureKey) => {
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
}
const orderFieldChanged = orderBy === 'id'
? oldItem.id !== updatedItem.id
: (oldItem.name || '').toLowerCase() !== (updatedItem.name || '').toLowerCase();
const orderFieldChanged = compareBy(oldItem, updatedItem, orderBy) !== 0;
if (orderFieldChanged) {
// Re-insert in sorted position if the ordering field changed
@@ -426,6 +414,24 @@ export const NomenclatureProvider = ({ children }) => {
...prev,
[nomenclatureKey]: updatedArray
};
} else if (operation === 'archived') {
// Vehicle finalized (printed/downloaded) - remove from the active list
const filteredArray = currentArray.filter(item => item.id !== data.id);
return {
...prev,
[nomenclatureKey]: filteredArray
};
} else if (operation === 'voided') {
// Vehicle voided - update in place if still present; safe no-op otherwise
const exists = currentArray.some(item => item.id === data.id);
if (!exists) {
return prev;
}
const updatedArray = updateSorted(currentArray, data, nomenclatureKey);
return {
...prev,
[nomenclatureKey]: updatedArray
};
}
return prev;
+78
View File
@@ -0,0 +1,78 @@
import React, { createContext, useState, useEffect, useContext, useCallback } from 'react';
/**
* Theme selection, applied as `data-theme` on <html>.
*
* The attribute lives on documentElement rather than on a React root div so
* that anything rendered outside the tree — portals, overlays, the native
* scrollbar via color-scheme — picks the theme up too.
*
* `classic` is the default on purpose: an operator's screen must not change
* because a new theme shipped. Cockpit is opt-in, per browser.
*/
const ThemeContext = createContext(null);
export const THEMES = [
{ id: 'classic', label: 'Classic' },
{ id: 'cockpit', label: 'Cockpit' }
];
const STORAGE_KEY = 'scalesapp.theme';
const DEFAULT_THEME = 'classic';
const isValid = (id) => THEMES.some(t => t.id === id);
/** Read the stored choice. localStorage throws in some privacy modes, and can
* hold a theme id that no longer exists, so both are handled here once. */
const readStored = () => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return isValid(saved) ? saved : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
};
export const ThemeProvider = ({ children }) => {
const [theme, setThemeState] = useState(readStored);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const setTheme = useCallback((id) => {
if (!isValid(id)) return;
setThemeState(id);
try {
localStorage.setItem(STORAGE_KEY, id);
} catch {
// A theme that cannot be remembered is still worth applying for this
// session, so a storage failure is not an error the user needs to see.
}
}, []);
const toggleTheme = useCallback(() => {
setThemeState(prev => {
const next = prev === 'classic' ? 'cockpit' : 'classic';
try {
localStorage.setItem(STORAGE_KEY, next);
} catch { /* see above */ }
return next;
});
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme, themes: THEMES }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
};
export default ThemeContext;
@@ -0,0 +1,334 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import useStagedList, { reconcileStaged, advanceStaged, initialStagedState } from '../useStagedList';
global.IS_REACT_ACT_ENVIRONMENT = true;
const v = (id, extra = {}) => ({ id, vehicle_number: `CA${id}`, ...extra });
const getKey = x => x.id;
const view = entries => entries.map(e => `${e.phase}:${e.key}`);
describe('useStagedList (hook)', () => {
let container, root, latest;
function Harness({ items, query = '', options }) {
const entries = useStagedList(items, { getKey, ...options });
latest = query ? entries.filter(e => e.item.vehicle_number.includes(query)) : entries;
return null;
}
const render = (items, query, options) =>
act(() => root.render(<Harness items={items} query={query} options={options} />));
const tick = ms => act(() => { vi.advanceTimersByTime(ms); });
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement('div');
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
vi.useRealTimers();
});
it('shows the initial load immediately, including empty -> many', () => {
render([]);
expect(view(latest)).toEqual([]);
render([v(1), v(2), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
expect(vi.getTimerCount()).toBe(0);
});
it('shows a marker for an added item, then the row after the delay', () => {
render([v(1), v(3)]);
render([v(1), v(2), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'pending:2', 'stable:3']);
tick(2999);
expect(view(latest)).toEqual(['stable:1', 'pending:2', 'stable:3']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'entering:2', 'stable:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
});
it('keeps a removed item as leaving, then removes it', () => {
render([v(1), v(2), v(3)]);
render([v(1), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'leaving:2', 'stable:3']);
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'stable:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:3']);
});
it('gives overlapping changes independent timers', () => {
render([v(1), v(3), v(5)]);
render([v(1), v(2), v(3), v(5)]);
tick(1500);
render([v(1), v(2), v(3), v(4)]); // add 4, remove 5, 1.5s later
expect(view(latest)).toEqual(['stable:1', 'pending:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(1500);
expect(view(latest)).toEqual(['stable:1', 'entering:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(1500);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3', 'exiting:5', 'entering:4']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3', 'stable:4']);
});
it('times entering with enterMs and exiting with animationMs', () => {
const options = { enterMs: 800, animationMs: 280 };
render([v(1), v(2)], '', options);
render([v(1), v(3)], '', options); // remove 2, add 3
expect(view(latest)).toEqual(['stable:1', 'leaving:2', 'pending:3']);
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'entering:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'entering:3']);
tick(519);
expect(view(latest)).toEqual(['stable:1', 'entering:3']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'stable:3']);
});
it('skips the entering phase when enterMs is 0, keeping the exit animation', () => {
const options = { enterMs: 0, animationMs: 280 };
render([v(1), v(2)], '', options);
render([v(1), v(3)], '', options);
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'stable:3']);
});
it('cancels a pending add that is removed before the delay', () => {
render([v(1)]);
render([v(1), v(2)]);
tick(1000);
render([v(1)]);
expect(view(latest)).toEqual(['stable:1']);
tick(5000);
expect(view(latest)).toEqual(['stable:1']);
});
it('returns a leaving item to normal when it comes back', () => {
render([v(1), v(2), v(3)]);
render([v(1), v(3)]);
tick(1000);
render([v(1), v(2), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
tick(5000);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
});
it('applies content updates in place immediately', () => {
render([v(1), v(2)]);
render([v(1), v(2, { tare: 12000 })]);
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
expect(latest[1].item.tare).toBe(12000);
});
it('applies a filter change instantly, without staging', () => {
const items = [v(1), v(12), v(3)];
render(items);
render(items, 'CA1');
expect(view(latest)).toEqual(['stable:1', 'stable:12']);
render(items, '');
expect(view(latest)).toEqual(['stable:1', 'stable:12', 'stable:3']);
expect(vi.getTimerCount()).toBe(0);
});
it('distorts a marker for the last distortMs before it enters', () => {
const options = { distortMs: 650 };
render([v(1)], '', options);
render([v(1), v(2)], '', options);
tick(2349);
expect(view(latest)).toEqual(['stable:1', 'pending:2']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
tick(649);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'entering:2']);
});
describe('hold', () => {
const held = { distortMs: 650, hold: true };
const free = { distortMs: 650, hold: false };
it('keeps a pending entry calm past its delay, then distorts and enters on release', () => {
render([v(1)], '', held);
render([v(1), v(2)], '', held);
tick(10000);
expect(view(latest)).toEqual(['stable:1', 'pending:2']);
render([v(1), v(2)], '', free);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
tick(650);
expect(view(latest)).toEqual(['stable:1', 'entering:2']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
});
it('lets a pending entry finish its calm time normally if released before it is due', () => {
render([v(1)], '', held);
render([v(1), v(2)], '', held);
tick(1000);
render([v(1), v(2)], '', free);
expect(view(latest)).toEqual(['stable:1', 'pending:2']);
tick(1350);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
});
it('keeps a leaving entry leaving past its delay, then exits on release', () => {
render([v(1), v(2)], '', held);
render([v(1)], '', held);
tick(10000);
expect(view(latest)).toEqual(['stable:1', 'leaving:2']);
render([v(1)], '', free);
expect(view(latest)).toEqual(['stable:1', 'exiting:2']);
tick(280);
expect(view(latest)).toEqual(['stable:1']);
});
it('does not freeze entries already distorting, entering or exiting', () => {
render([v(1), v(9)], '', free);
render([v(1), v(2), v(3)], '', free); // add 2 and 3, remove 9
tick(2350);
render([v(1), v(2), v(3)], '', held);
expect(view(latest)).toEqual(['stable:1', 'leaving:9', 'distorting:2', 'distorting:3']);
tick(650); // 9 is due now, but held
expect(view(latest)).toEqual(['stable:1', 'leaving:9', 'entering:2', 'entering:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'leaving:9', 'stable:2', 'stable:3']);
render([v(1), v(3)], '', free); // 9 exits, 2 leaves
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'stable:3']);
render([v(1), v(3)], '', held); // hold while 2 is exiting
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:3']);
});
it('drops a pending entry removed while held, and restores a leaving one re-added', () => {
render([v(1), v(2)], '', held);
render([v(1), v(3)], '', held); // remove 2, add 3
tick(5000);
expect(view(latest)).toEqual(['stable:1', 'leaving:2', 'pending:3']);
render([v(1), v(2)], '', held); // 3 cancelled, 2 back
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
render([v(1), v(2)], '', free);
tick(5000);
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
expect(vi.getTimerCount()).toBe(0);
});
it('applies a bulk update immediately while held', () => {
render([v(1), v(2)], '', held);
render([v(1)], '', held);
tick(5000);
const many = Array.from({ length: 12 }, (_, i) => v(100 + i));
render(many, '', held);
expect(latest.every(e => e.phase === 'stable')).toBe(true);
expect(latest.map(e => e.key)).toEqual(many.map(getKey));
});
it('releases several overdue entries top to bottom, releaseStagger apart', () => {
const opts = hold => ({ ...held, hold, releaseStagger: 70 });
render([v(1), v(3), v(5)], '', opts(true));
render([v(2), v(3), v(4)], '', opts(true)); // -1 +2, +4 -5
tick(5000);
expect(view(latest)).toEqual(['leaving:1', 'pending:2', 'stable:3', 'leaving:5', 'pending:4']);
render([v(2), v(3), v(4)], '', opts(false));
expect(view(latest)).toEqual(['exiting:1', 'pending:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(70);
expect(view(latest)).toEqual(['exiting:1', 'distorting:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(70);
expect(view(latest)).toEqual(['exiting:1', 'distorting:2', 'stable:3', 'exiting:5', 'pending:4']);
render([v(2), v(3), v(4)], '', opts(true)); // held again mid-stagger: 4 keeps waiting
tick(580); // 2 enters, 5 is gone
tick(280); // 2 settles
tick(5000);
expect(view(latest)).toEqual(['stable:2', 'stable:3', 'pending:4']);
render([v(2), v(3), v(4)], '', opts(false));
expect(view(latest)).toEqual(['stable:2', 'stable:3', 'distorting:4']);
});
it('releases all overdue entries at once without a stagger', () => {
render([v(1), v(3)], '', held);
render([v(2), v(4)], '', held);
tick(5000);
render([v(2), v(4)], '', free);
expect(view(latest)).toEqual(['exiting:1', 'exiting:3', 'distorting:2', 'distorting:4']);
});
});
it('clears all timers on unmount', () => {
render([v(1)]);
render([v(2)]);
expect(vi.getTimerCount()).toBeGreaterThan(0);
act(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
root = createRoot(container); // afterEach unmounts again
});
});
describe('reconcileStaged / advanceStaged (pure)', () => {
const start = items => reconcileStaged(initialStagedState, items, getKey);
it('treats a re-sort as a move: leaving at the old spot, marker at the new', () => {
const s0 = start([v(1), v(2), v(3)]);
const s1 = reconcileStaged(s0, [v(2), v(3), v(1)], getKey);
expect(view(s1.entries)).toEqual(['leaving:1', 'stable:2', 'stable:3', 'pending:1']);
// The two entries for key 1 have distinct ids (React keys).
expect(s1.entries[0].id).not.toBe(s1.entries[3].id);
});
it('applies bulk replacements without staging', () => {
const s0 = start([v(1), v(2)]);
const many = Array.from({ length: 12 }, (_, i) => v(100 + i));
const s1 = reconcileStaged(s0, many, getKey, { bulkThreshold: 10 });
expect(s1.entries.every(e => e.phase === 'stable')).toBe(true);
expect(s1.entries.map(e => e.key)).toEqual(many.map(getKey));
});
it('returns the same state when nothing changed', () => {
const items = [v(1), v(2)];
const s0 = start(items);
expect(reconcileStaged(s0, items, getKey)).toBe(s0);
});
it('skips the animation phases when motion is reduced', () => {
const s0 = start([v(1)]);
const s1 = reconcileStaged(s0, [v(2)], getKey);
expect(view(s1.entries)).toEqual(['leaving:1', 'pending:2']);
const [leaving, pending] = s1.entries;
const s2 = advanceStaged(s1, pending.id, 'pending', false);
const s3 = advanceStaged(s2, leaving.id, 'leaving', false);
expect(view(s3.entries)).toEqual(['stable:2']);
});
it('sends a pending entry through distorting when asked to', () => {
const s0 = start([v(1)]);
const s1 = reconcileStaged(s0, [v(1), v(2)], getKey);
const id = s1.entries[1].id;
const s2 = advanceStaged(s1, id, 'pending', true, true);
expect(view(s2.entries)).toEqual(['stable:1', 'distorting:2']);
expect(view(advanceStaged(s2, id, 'distorting').entries)).toEqual(['stable:1', 'entering:2']);
expect(view(advanceStaged(s2, id, 'distorting', false).entries)).toEqual(['stable:1', 'stable:2']);
});
it('treats a distorting marker like a pending one: it moves along, or is dropped', () => {
const s0 = start([v(1), v(3), v(4)]);
const s1 = reconcileStaged(s0, [v(1), v(2), v(3), v(4)], getKey);
const s2 = advanceStaged(s1, s1.entries[1].id, 'pending', true, true);
const moved = reconcileStaged(s2, [v(1), v(3), v(4), v(2)], getKey);
expect(view(moved.entries)).toEqual(['stable:1', 'stable:3', 'stable:4', 'distorting:2']);
expect(moved.entries[3].id).toBe(s2.entries[1].id);
expect(view(reconcileStaged(s2, [v(1), v(3), v(4)], getKey).entries))
.toEqual(['stable:1', 'stable:3', 'stable:4']);
});
it('ignores a stale timer for an entry whose phase changed', () => {
const s0 = start([v(1)]);
const s1 = reconcileStaged(s0, [], getKey);
const id = s1.entries[0].id;
const s2 = reconcileStaged(s1, [v(1)], getKey); // restored
expect(advanceStaged(s2, id, 'leaving')).toBe(s2);
});
});
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useReducer, useRef } from 'react';
/**
* Display layer for a live, sorted list that changes under the user's cursor.
*
* Returns entries `{ id, key, item, phase }` in display order, lagging the
* live list so rows never move without warning:
*
* pending - item is in the live list but not shown yet; render a marker
* where it will appear. After `delay - distortMs` -> distorting
* (or, with `distortMs` = 0, after `delay` -> entering).
* distorting - the marker's last moment before the row opens. After
* `distortMs` -> entering.
* entering - row is animating in. After `enterMs` -> stable.
* stable - normal row.
* leaving - row is gone from the live list but kept in place, marked.
* After `delay` -> exiting.
* exiting - row is animating out. After `animationMs` it is dropped.
*
* `enterMs` defaults to `animationMs`; a value of 0 skips the entering phase
* (and `animationMs` = 0 skips exiting).
*
* `hold` (read live) parks the list while the user is pointing at it: a
* pending entry whose time is up stays pending (no distortion) and a leaving
* one stays leaving. Entries already distorting, entering or exiting finish.
* When `hold` turns false the overdue entries proceed in display order,
* `releaseStagger` ms apart.
*
* A row whose sort position changes becomes a `leaving` ghost at the old spot
* plus a `pending` entry at the new one. Each entry has its own timer.
* Content updates (same key, same position) apply immediately.
*
* Not staged: the first non-empty data (or any time nothing was shown), and
* bulk replacements touching more than `bulkThreshold` rows (a full reload).
* With prefers-reduced-motion the distorting/entering/exiting phases are
* skipped; the pending/leaving warning still lasts `delay`.
*
* `id` is unique per entry (a moved item briefly has two), use it as the
* React key. `getKey` may be an inline function.
*/
const LIVE_PHASES = new Set(['pending', 'distorting', 'entering', 'stable']);
const HOLDABLE = new Set(['pending', 'leaving']);
const isMarker = phase => phase === 'pending' || phase === 'distorting';
const HEAD = Symbol('head');
export const initialStagedState = { entries: [], nextId: 1 };
// Indices (into `seq`) of one longest strictly increasing subsequence.
function longestIncreasing(seq) {
const tails = []; // tails[len] = index in seq of the smallest tail for length len+1
const prev = new Array(seq.length);
for (let i = 0; i < seq.length; i++) {
let lo = 0, hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (seq[tails[mid]] < seq[i]) lo = mid + 1; else hi = mid;
}
prev[i] = lo > 0 ? tails[lo - 1] : -1;
tails[lo] = i;
}
const result = new Set();
for (let i = tails.length ? tails[tails.length - 1] : -1; i !== -1; i = prev[i]) result.add(i);
return result;
}
function sameEntries(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i], y = b[i];
if (x.id !== y.id || x.phase !== y.phase || x.item !== y.item) return false;
}
return true;
}
/** Merge a new live list into the displayed state. Pure. */
export function reconcileStaged(state, items, getKey, { bulkThreshold = 10 } = {}) {
const prev = state.entries;
let nextId = state.nextId;
const liveKeys = items.map(getKey);
const liveMap = new Map();
liveKeys.forEach((k, i) => liveMap.set(k, items[i]));
const liveIndex = new Map(liveKeys.map((k, i) => [k, i]));
// The entry currently representing each key at its live position. A
// `leaving` ghost whose key came back is a candidate to be restored.
const current = new Map();
for (const e of prev) if (LIVE_PHASES.has(e.phase)) current.set(e.key, e);
for (const e of prev) {
if (e.phase === 'leaving' && liveMap.has(e.key) && !current.has(e.key)) current.set(e.key, e);
}
// Survivors (still live) in previous display order; those on a longest
// increasing run of new positions stay put, the rest have moved.
const survivors = prev.filter(e => current.get(e.key) === e && liveMap.has(e.key));
const staySet = longestIncreasing(survivors.map(e => liveIndex.get(e.key)));
const staying = new Set(survivors.filter((_, i) => staySet.has(i)).map(e => e.key));
const added = liveKeys.filter(k => !current.has(k)).length;
const removed = [...current.values()].filter(e => e.phase !== 'leaving' && !liveMap.has(e.key)).length;
const moved = survivors.length - staying.size;
const hadRows = prev.some(e => LIVE_PHASES.has(e.phase));
if (!hadRows || added + removed + moved > bulkThreshold) {
const entries = liveKeys.map(k => ({
id: current.get(k)?.id ?? nextId++,
key: k,
item: liveMap.get(k),
phase: 'stable',
}));
const next = { entries, nextId };
return sameEntries(prev, entries) ? state : next;
}
const placed = new Map();
const ghostsAfter = new Map();
const addGhost = (anchor, entry) => {
if (!ghostsAfter.has(anchor)) ghostsAfter.set(anchor, []);
ghostsAfter.get(anchor).push(entry);
};
let anchor = HEAD;
for (const e of prev) {
const isCurrent = current.get(e.key) === e;
if (isCurrent && liveMap.has(e.key)) {
const item = liveMap.get(e.key);
if (staying.has(e.key)) {
placed.set(e.key, { ...e, item, phase: e.phase === 'leaving' ? 'stable' : e.phase });
anchor = e.key;
} else if (isMarker(e.phase)) {
placed.set(e.key, { ...e, item }); // marker just moves, its timer keeps running
} else {
// Moved: warn at the old spot, marker at the new one.
addGhost(anchor, e.phase === 'leaving' ? e : { ...e, item, phase: 'leaving' });
placed.set(e.key, { id: nextId++, key: e.key, item, phase: 'pending' });
}
} else if (isCurrent) {
// Gone from the live list. A pending add is simply cancelled.
if (!isMarker(e.phase)) addGhost(anchor, e.phase === 'leaving' ? e : { ...e, phase: 'leaving' });
} else if (e.phase === 'leaving' || e.phase === 'exiting') {
addGhost(anchor, e);
}
}
const entries = [...(ghostsAfter.get(HEAD) || [])];
for (const k of liveKeys) {
entries.push(placed.get(k) || { id: nextId++, key: k, item: liveMap.get(k), phase: 'pending' });
if (ghostsAfter.has(k)) entries.push(...ghostsAfter.get(k));
}
return sameEntries(prev, entries) ? state : { entries, nextId };
}
/** Move one entry to its next phase when its timer fires. Pure. `distort`
* sends a pending entry to distorting instead of straight to entering. */
export function advanceStaged(state, id, phase, animate = true, distort = false) {
const index = state.entries.findIndex(e => e.id === id);
if (index === -1 || state.entries[index].phase !== phase) return state;
const NEXT = animate
? { pending: distort ? 'distorting' : 'entering', distorting: 'entering', entering: 'stable', leaving: 'exiting', exiting: null }
: { pending: 'stable', distorting: 'stable', entering: 'stable', leaving: null, exiting: null };
const nextPhase = NEXT[phase];
const entries = nextPhase
? state.entries.map((e, i) => (i === index ? { ...e, phase: nextPhase } : e))
: state.entries.filter((_, i) => i !== index);
return { ...state, entries };
}
function reducer(state, action) {
switch (action.type) {
case 'sync':
return reconcileStaged(state, action.items, action.getKey, action.options);
case 'advance':
return advanceStaged(state, action.id, action.phase, action.animate, action.distort);
default:
return state;
}
}
export const prefersReducedMotion = () =>
typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
export default function useStagedList(items, {
getKey, delay = 3000, animationMs = 280, enterMs = animationMs, distortMs = 0, bulkThreshold = 10,
hold = false, releaseStagger = 0,
} = {}) {
const getKeyRef = useRef(getKey);
getKeyRef.current = getKey;
const holdRef = useRef(hold);
holdRef.current = hold;
const [state, dispatch] = useReducer(reducer, undefined,
() => reconcileStaged(initialStagedState, items, getKey, { bulkThreshold }));
useEffect(() => {
dispatch({ type: 'sync', items, getKey: getKeyRef.current, options: { bulkThreshold } });
}, [items, bulkThreshold]);
const entriesRef = useRef(state.entries);
entriesRef.current = state.entries;
// One timer per entry in a timed phase; kept while the entry stays in
// that phase, so a new change never resets the others. A pending/leaving
// timer that fires while held marks its entry overdue (no handle) and
// waits for the release below.
const timersRef = useRef(new Map());
useEffect(() => {
const timers = timersRef.current;
const wanted = new Map();
for (const e of state.entries) {
if (e.phase !== 'stable') wanted.set(e.id, e.phase);
}
for (const [id, t] of timers) {
if (wanted.get(id) !== t.phase) {
clearTimeout(t.handle);
timers.delete(id);
}
}
for (const [id, phase] of wanted) {
if (timers.has(id)) continue;
const distort = phase === 'pending' && distortMs > 0 && !prefersReducedMotion();
const ms = {
pending: distort ? delay - distortMs : delay,
distorting: distortMs, leaving: delay, entering: enterMs, exiting: animationMs,
}[phase];
// The phase this timer leads into, if animated, lasts this long.
const nextMs = { pending: distort ? distortMs : enterMs, distorting: enterMs }[phase] ?? animationMs;
const t = { phase, handle: null, overdue: false };
t.fire = () => {
t.handle = null;
if (holdRef.current && HOLDABLE.has(phase)) {
t.overdue = true;
return;
}
timers.delete(id);
dispatch({ type: 'advance', id, phase, animate: nextMs > 0 && !prefersReducedMotion(), distort });
};
t.handle = setTimeout(t.fire, ms);
timers.set(id, t);
}
}, [state.entries, delay, animationMs, enterMs, distortMs]);
// Released: overdue entries go on, top to bottom. A staggered one that
// is still waiting when the hold returns becomes overdue again.
useEffect(() => {
if (hold) return;
const timers = timersRef.current;
let wait = 0;
for (const e of entriesRef.current) {
const t = timers.get(e.id);
if (!t?.overdue) continue;
t.overdue = false;
if (wait === 0) t.fire();
else t.handle = setTimeout(t.fire, wait);
wait += releaseStagger;
}
}, [hold, releaseStagger]);
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const t of timers.values()) clearTimeout(t.handle);
timers.clear();
};
}, []);
return state.entries;
}
+6 -6
View File
@@ -1,16 +1,16 @@
@import './styles/tokens.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
background-color: var(--bg);
color: var(--text);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
font-family: var(--font-mono);
}
* {
@@ -0,0 +1,42 @@
import { buildVehicleReportData, vehicleNetWeight } from '../vehicleReportData';
describe('vehicleNetWeight', () => {
test('null vehicle has no net', () => {
expect(vehicleNetWeight(null)).toBeNull();
});
test('two weighings without a server net derive gross - tare', () => {
expect(vehicleNetWeight({ tare: 12000, gross: 28500, net: null })).toBe(16500);
});
test('the server net wins over the derived value', () => {
expect(vehicleNetWeight({ tare: 12000, gross: 28500, net: 16400 })).toBe(16400);
});
test('a one-weight vehicle (tare null, net = gross) uses net', () => {
expect(vehicleNetWeight({ tare: null, gross: 28500, net: 28500 })).toBe(28500);
});
test('a single open weighing has no net', () => {
expect(vehicleNetWeight({ tare: 12000, gross: null, net: null })).toBeNull();
expect(vehicleNetWeight({ tare: null, gross: 28500, net: null })).toBeNull();
});
});
describe('buildVehicleReportData', () => {
test('a one-weight vehicle reports net = gross and keeps tare null', () => {
const out = buildVehicleReportData(
{ id: 1, tare: null, gross: 28500, net: 28500, doc_number: '2026-001', extra: { data: {} } },
{},
{}
);
expect(out.vehicle.net).toBe(28500);
expect(out.vehicle.tare).toBeNull();
expect(out.vehicle.gross).toBe(28500);
});
test('two weighings without a server net still derive net', () => {
const out = buildVehicleReportData({ id: 2, tare: 12000, gross: 28500 }, {}, {});
expect(out.vehicle.net).toBe(16500);
});
});
@@ -0,0 +1,52 @@
/**
* Net weight of a vehicle: the server's `net` when set, otherwise gross - tare
* when both are recorded, otherwise null. The server value comes first because
* a one-weight vehicle is finalized with tare = null and net = gross.
*
* @param {Object|null} vehicle
* @returns {number|null}
*/
export function vehicleNetWeight(vehicle) {
if (!vehicle) return null;
if (vehicle.net != null) return vehicle.net;
return (vehicle.tare != null && vehicle.gross != null)
? vehicle.gross - vehicle.tare
: null;
}
/**
* Shapes a vehicle for the report preview/design/print machinery.
*
* Transforms extra.data keys from raw nomenclature codes/entry ids into
* { [nomenclature name]: display value }, and attaches the derived net
* weight. Shared between Main.jsx (the active weighing screen) and History
* (completed vehicles) so both feed the same {vehicle} shape into
* ReportPreviewModal/printA4Report.
*
* @param {Object} vehicle - a vehicle record (as returned by the API)
* @param {Object} definitions - nomenclature definitions keyed by code
* @param {Object} entries - lookup entries keyed by code
* @returns {{vehicle: Object} | null}
*/
export function buildVehicleReportData(vehicle, definitions, entries) {
if (!vehicle) return null;
const rawExtra = vehicle.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 ?? '';
}
}
return {
vehicle: {
...vehicle,
net: vehicleNetWeight(vehicle),
extra,
}
};
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Design tokens — the single place every colour, radius, shadow, font size
* and spacing step is defined.
*
* Two themes today: `classic` (the app's original light look) and `cockpit`
* (dark, derived from the weigh-station reference image). A third theme is one
* more block here and nothing else, which is why the token names are SEMANTIC
* (--surface, --accent) and never literal (--navy, --cyan) — a name that
* describes a colour stops being true the moment another theme uses a
* different one.
*
* The `classic` values are lifted verbatim from the stylesheets they replaced,
* so switching to classic must be visually indistinguishable from the app
* before theming existed. That is the acceptance test for this file.
*
* Cockpit's palette is sampled from src/assets/weigh-station-banner.jpg rather
* than invented, which is what keeps it coherent: the navies, steel mid-tones
* and cyan all already sit together in one photograph.
*/
:root,
:root[data-theme="classic"] {
/* Surfaces, back to front */
--bg: #f5f6f8;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-sunken: #fafafa;
--surface-hover: #f0f2f5;
/* Lines */
--border: #e0e0e0;
--border-strong: #c8ccd4;
/* Type */
--text: #2c3e50;
--text-muted: #7f8c9a;
--text-inverse: #ffffff;
/* Brand / interactive */
--accent: #667eea;
--accent-hover: #5a6fd6;
--accent-contrast: #ffffff;
--accent-soft: rgba(102, 126, 234, 0.12);
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* States */
--success: #27ae60;
--success-soft: rgba(39, 174, 96, 0.12);
--warning: #e67e22;
--warning-soft: rgba(230, 126, 34, 0.12);
--danger: #e74c3c;
--danger-soft: rgba(231, 76, 60, 0.12);
/* The live scale reading: its own tokens because balanced/unbalanced is the
one piece of colour an operator reads from across the room. */
--reading: #2c3e50;
--reading-balanced: #27ae60;
--reading-unbalanced: #e67e22;
--reading-glow: none;
/* Shape and depth */
--radius: 8px;
--radius-sm: 6px;
--radius-lg: 12px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-raised: 0 3px 8px rgba(0, 0, 0, 0.12);
--shadow-overlay: 0 8px 32px rgba(0, 0, 0, 0.18);
/* The dim behind a modal */
--backdrop: rgba(0, 0, 0, 0.5);
/* The desk the A4 designer's white page sits on, and the page-break label
drawn on it. Mid-grey, as in a word processor, so the page edge reads. */
--desk: #8a8a8a;
--desk-text: #e8e8e8;
/* The header is its own surface: a saturated bar with white chrome on it,
so it cannot borrow --surface/--text without losing that contrast. */
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--header-text: #ffffff;
--header-chrome: rgba(255, 255, 255, 0.2);
--header-chrome-hover: rgba(255, 255, 255, 0.3);
--header-chrome-border: rgba(255, 255, 255, 0.3);
--header-avatar-bg: #ffffff;
--header-avatar-text: #667eea;
/* Active-item edge and focus ring on the rail. Not --accent: the accent is
the gradient's own start colour, so it would vanish on this bar. */
--header-active: #ffffff;
/* Cockpit-only chrome, neutralised here so shared rules need no theme test */
--hero-image: none;
}
:root[data-theme="cockpit"] {
/* Sampled: #000018 / #183048 / #001830 dominate the reference image */
--bg: #020a12;
--surface: #061726;
--surface-raised: #0a2336;
--surface-sunken: #03101c;
--surface-hover: #0e3047;
--border: #0d4f66;
--border-strong: #12809e;
--text: #d9f6ff;
--text-muted: #6e9caf;
--text-inverse: #070d18;
/* Sampled cyan, lifted in saturation: the photo's cyan is atmospheric haze,
which is too dim to carry a button label. */
--accent: #00d9ff;
--accent-hover: #5ce8ff;
--accent-contrast: #00232e;
--accent-soft: rgba(0, 217, 255, 0.14);
--accent-gradient: linear-gradient(135deg, #00d9ff 0%, #19a9ff 100%);
--success: #00f19b;
--success-soft: rgba(0, 241, 155, 0.14);
--warning: #ff6b24;
--warning-soft: rgba(255, 107, 36, 0.16);
--danger: #ff4d4d;
--danger-soft: rgba(226, 87, 76, 0.18);
--reading: #00d9ff;
--reading-balanced: #00f19b;
--reading-unbalanced: #ff9a3c;
/* The glow is what makes the reading read as a lit instrument rather than
text on a dark box. Kept to the reading alone — everywhere else it is
noise that costs contrast. */
--reading-glow: 0 0 10px rgba(0, 217, 255, .75), 0 0 28px rgba(0, 217, 255, .45);
--radius: 8px;
--radius-sm: 6px;
--radius-lg: 12px;
/* Depth on dark comes from a lit top edge, not a drop shadow: a black shadow
on a near-black background is invisible. */
--shadow-card: 0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 2px 8px rgba(0, 0, 0, 0.5);
--shadow-raised: 0 1px 0 rgba(255, 255, 255, 0.06) inset,
0 6px 18px rgba(0, 0, 0, 0.6);
--shadow-overlay: 0 12px 48px rgba(0, 0, 0, 0.7);
/* Tinted with --bg rather than pure black, so the page behind a modal
sinks into the theme instead of turning grey. */
--backdrop: rgba(2, 10, 18, 0.75);
/* Darker than --bg so the white page is the brightest thing on screen */
--desk: #01070d;
--desk-text: #6e9caf;
--header-bg: linear-gradient(180deg, #0a2336 0%, #04121e 100%);
--header-text: #d9f6ff;
--header-chrome: rgba(0, 217, 255, 0.10);
--header-chrome-hover: rgba(0, 217, 255, 0.22);
--header-chrome-border: rgba(0, 217, 255, 0.34);
--header-avatar-bg: #00d9ff;
--header-avatar-text: #00232e;
--header-active: var(--accent);
/* Wide crop of the reference photograph, shown beside the vehicle title in
.vehicle-hero-photo, scaled proportionally (background-size: contain)
rather than cropped or stretched. */
--hero-image: url('../assets/bar2.png');
/* Lighting. A lit edge is a gradient that fades at both ends — a flat border
reads as a box, the fade reads as a light source above the card. */
--edge-lit: linear-gradient(90deg, transparent, var(--accent) 18%, var(--accent) 82%, transparent);
--edge-lit-net: linear-gradient(90deg, transparent, var(--success) 22%, var(--success) 78%, transparent);
--glow-accent: 0 0 26px rgba(0, 217, 255, .30), 0 0 60px rgba(0, 217, 255, .10);
--glow-success: 0 0 26px rgba(0, 241, 155, .30), 0 0 60px rgba(0, 241, 155, .10);
--glow-warning: 0 0 26px rgba(255, 107, 36, .45), 0 0 60px rgba(255, 107, 36, .15);
--glow-danger: 0 0 26px rgba(255, 77, 77, .40), 0 0 60px rgba(255, 77, 77, .15);
--hazard: repeating-linear-gradient(-45deg, var(--warning) 0 7px, transparent 7px 15px);
}
/* Type and spacing are shared by every theme, so they live in a plain :root
block rather than being repeated per theme. A theme that wants a different
value (say a larger --fs-display for cockpit) overrides just that token in
its own block, whose attribute selector outranks this one. */
:root {
/* 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 — a 4px grid plus the half-steps (6, 10) this UI leans on too
often to snap away. Off-scale values snap to the nearest step. */
--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;
}
/* Native form controls and scrollbars follow the theme, so a dark theme does
not get a white dropdown or a light scrollbar track. */
:root[data-theme="cockpit"] {
color-scheme: dark;
}
+1
View File
@@ -2,6 +2,7 @@ Pillow==12.1.0
pyserial==3.5
python-dotenv==1.0.0
flask==3.1.0
flask-cors==5.0.0
requests==2.32.5
urllib3==2.6.3
pystray==0.19.5
+8
View File
@@ -0,0 +1,8 @@
- history weight equilazer, history running weight in a window
- option to input truck data separately on gate/dispatcher, then only measure at the scales
- show up to 2 cameras
- 3m card reader
###
Depending on the type of card (e.g., standard ID cards vs. Passports), the reader will output either a 2-line or 3-line text block. Every single line is exactly 30, 36, or 44 characters long, padded with filler arrows (<).For standard Type 1 (TD1) Identity Cards (the most common for regional IDs), the format is 3 lines of 30 characters each:Line 1: Document Type (2 chars) + Issuing Country (3 chars) + Card ID/Document Number (9 chars) + Optional elements.Line 2: Date of Birth (6 chars YYMMDD) + Gender (1 char) + Expiry Date (6 chars) + Nationality (3 chars).Line 3: Holder’s Name (Primary identifier/Last name, followed by << then First names).
###