added print, download as pdf. no puppeteer, we'll see

This commit is contained in:
2026-09-22 22:06:06 +03:00
parent 122480cd46
commit 6f614270e7
37 changed files with 3955 additions and 473 deletions
+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:
+65 -6
View File
@@ -228,14 +228,19 @@ 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)
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 +256,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 +333,53 @@ class VehicleViewSet(viewsets.ModelViewSet):
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",
),
),
]
+15 -2
View File
@@ -8,11 +8,12 @@ from api.models import User
# Create your models here.
class Vehicle(models.Model):
vehicle_number = models.CharField(max_length=15, unique=True)
vehicle_number = models.CharField(max_length=15)
trailer1_number = models.CharField(max_length=15, null=True, blank=True)
trailer2_number = models.CharField(max_length=15, null=True, blank=True)
driver_pid = models.CharField(max_length=50, null=True, blank=True)
driver_name = models.CharField(max_length=50, null=True, blank=True)
tare = models.IntegerField(null=True, blank=True)
tare_date = models.DateTimeField(null=True, blank=True)
tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL)
@@ -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
+10 -10
View File
@@ -1,33 +1,33 @@
.App {
min-height: 100vh;
background-color: #f5f5f5;
background-color: var(--bg);
display: flex;
flex-direction: column;
}
.status-bar {
background: white;
background: var(--surface);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
box-shadow: var(--shadow-card);
}
.status-connected {
color: #4CAF50;
color: var(--success);
font-size: 14px;
font-weight: 500;
}
.status-disconnected {
color: #f44336;
color: var(--danger);
font-size: 14px;
font-weight: 500;
}
.status-error {
color: #f44336;
color: var(--danger);
font-size: 12px;
}
@@ -40,19 +40,19 @@
}
.error-message {
background-color: #f8d7da;
color: #721c24;
background-color: var(--danger-soft);
color: var(--danger);
padding: 12px 20px;
border-radius: 4px;
margin-bottom: 20px;
border: 1px solid #f5c6cb;
border: 1px solid var(--danger);
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #666;
color: var(--text-muted);
}
@media (max-width: 768px) {
+32 -3
View File
@@ -1,11 +1,13 @@
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 './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
@@ -16,6 +18,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();
@@ -69,6 +78,7 @@ function AppContent() {
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
<Routes>
<Route path="/" element={<Main />} />
<Route path="/history" element={<History />} />
<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="/" />} />
@@ -87,9 +97,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

@@ -5,7 +5,7 @@
.extra-combos h3 {
margin: 0 0 8px 0;
font-size: 12px;
color: #888;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -13,9 +13,9 @@
.extra-combos-error {
padding: 6px 10px;
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
background-color: var(--danger-soft);
color: var(--danger);
border: 1px solid var(--danger);
border-radius: 6px;
font-size: 12px;
margin-bottom: 8px;
@@ -25,7 +25,7 @@
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,7 +54,7 @@
}
.extra-combos-table thead {
background-color: #f8f9fa;
background-color: var(--surface-sunken);
position: sticky;
top: 0;
z-index: 1;
@@ -64,9 +64,9 @@
padding: 0.4rem 0.75rem;
text-align: left;
font-weight: 600;
color: #495057;
color: var(--text-muted);
font-size: 0.75rem;
border-bottom: 2px solid #dee2e6;
border-bottom: 2px solid var(--border);
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.3px;
@@ -78,9 +78,9 @@
.extra-combos-table td {
padding: 0.4rem 0.75rem;
border-bottom: 1px solid #dee2e6;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
color: #333;
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;
border: 1px solid var(--border);
padding: 0 0.35rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
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;
color: var(--text-muted);
font-style: italic;
font-size: 0.85rem;
}
+48 -23
View File
@@ -1,8 +1,8 @@
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
background: var(--header-bg);
color: var(--header-text);
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
box-shadow: var(--shadow-raised);
position: sticky;
top: 0;
z-index: 100;
@@ -32,10 +32,10 @@
.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;
border-radius: var(--radius);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
font-size: 20px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
@@ -46,7 +46,7 @@
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.3);
background: var(--header-chrome-hover);
transform: scale(1.05);
}
@@ -70,9 +70,9 @@
left: 0;
z-index: 101;
min-width: 220px;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
@@ -84,16 +84,16 @@
border: none;
text-align: left;
cursor: pointer;
color: #333;
color: var(--text);
font-family: inherit;
}
.nav-dropdown-menu button + button {
border-top: 1px solid #eee;
border-top: 1px solid var(--border);
}
.nav-dropdown-menu button:hover {
background: #f2f3fa;
background: var(--surface-hover);
}
.nav-dropdown-menu strong {
@@ -106,7 +106,7 @@
display: block;
margin-top: 2px;
font-size: 11px;
color: #777;
color: var(--text-muted);
}
.header-right {
@@ -142,8 +142,8 @@
width: 42px;
height: 42px;
border-radius: 50%;
background: white;
color: #667eea;
background: var(--header-avatar-bg);
color: var(--header-avatar-text);
display: flex;
align-items: center;
justify-content: center;
@@ -151,19 +151,19 @@
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-card);
}
.avatar:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow-raised);
}
.logout-button {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
@@ -172,7 +172,7 @@
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.3);
background: var(--header-chrome-hover);
}
@media (max-width: 768px) {
@@ -196,3 +196,28 @@
margin-right: 0;
}
}
/* Theme toggle — same chrome as the nav buttons, so it reads as part of the
header furniture rather than a feature being advertised. */
.theme-toggle {
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: 17px;
line-height: 1;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--header-chrome-hover);
transform: scale(1.05);
}
+17
View File
@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
@@ -74,6 +76,13 @@ function Header() {
📋
</button>
)}
<button
className="nav-button"
onClick={() => navigate('/history')}
title="History"
>
🕘
</button>
{currentUser?.is_admin && (
<button
className="nav-button"
@@ -104,6 +113,14 @@ function Header() {
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
<button
className="theme-toggle"
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="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
+182
View File
@@ -0,0 +1,182 @@
.history {
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px;
}
.history-title {
margin: 0 0 16px 0;
color: var(--text);
}
.history-error {
padding: 10px 14px;
margin-bottom: 12px;
background: var(--danger-soft);
color: var(--danger);
border-radius: var(--radius-sm);
font-size: 13px;
}
.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: 14px;
}
.history-table th {
text-align: left;
padding: 10px 14px;
background: var(--surface-hover);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
}
.history-table td {
padding: 10px 14px;
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: 32px !important;
}
.history-badge {
display: inline-block;
padding: 3px 10px;
border: 1px solid;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
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: 4px 12px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.history-void-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.history-void-inline {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.history-void-inline input {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
box-sizing: border-box;
}
.history-void-error {
color: var(--danger);
font-size: 11px;
}
.history-void-actions {
display: flex;
gap: 6px;
}
.history-void-actions button {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-hover);
color: var(--text);
font-size: 12px;
cursor: pointer;
}
.history-pager {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 16px;
}
.history-pager button {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 13px;
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 4px;
}
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useMemo, useState } from 'react';
import Header from './Header';
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>
<Header />
<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>
);
}
File diff suppressed because it is too large Load Diff
+277 -202
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
@@ -12,6 +12,7 @@ import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
import ReportPreviewModal from './ReportPreviewModal';
import ExtraCombinationsTable from './ExtraCombinationsTable';
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
import { buildVehicleReportData } from '../services/vehicleReportData';
import api from '../services/api';
import './Main.css';
import './License/LicenseManager.css';
@@ -27,7 +28,7 @@ export default function Main() {
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);
@@ -99,6 +100,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
@@ -152,7 +193,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 +209,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 +220,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;
@@ -285,53 +327,11 @@ export default function Main() {
const canMeasure = !!currentUser?.can_measure;
const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
// The single recorded weighing, while its tare/gross role is still undecided.
// The backend parks a lone reading in tare, but a lighter second reading can move it to gross.
const firstWeighing = useMemo(() => {
if (!selectedVehicle || isFinalized) return null;
if (selectedVehicle.tare != null) {
return {
value: selectedVehicle.tare,
date: selectedVehicle.tare_date,
userName: selectedVehicle.tare_user_name,
manual: selectedVehicle.tare_manual,
};
}
if (selectedVehicle.gross != null) {
return {
value: selectedVehicle.gross,
date: selectedVehicle.gross_date,
userName: selectedVehicle.gross_user_name,
manual: selectedVehicle.gross_manual,
};
}
return null;
}, [selectedVehicle, isFinalized]);
// Vehicle data shaped for report preview/design
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
const vehicleReportData = useMemo(() => {
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 +351,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 +366,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');
@@ -487,7 +483,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 +521,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 +566,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,179 +580,249 @@ 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>
<button
className="weight-set-btn"
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
{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 className="weight-card-value">
{netWeight !== null ? `${netWeight} kg` : '—'}
</div>
</div>
)}
</div>
{/* Action column. Print/Design only now - Measure/Manual entry
moved into the Current Reading card, since they act on that
card's live value. This column is empty (just its panel
chrome) until weighing is finalized. */}
<div className="weight-card weight-actions">
{netWeight !== null && (
<>
<button
className="weight-set-btn"
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
{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>
{/* 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">
@@ -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;
}
+129 -20
View File
@@ -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;
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: 4px;
}
.report-preview-close:hover {
color: #000;
.report-preview-switch-btn {
padding: 6px 14px;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
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: 6px 14px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
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;
background: var(--surface-sunken);
}
.report-preview-error {
padding: 24px;
color: #c00;
color: var(--danger);
font-size: 14px;
}
.report-preview-loading {
padding: 24px;
color: #666;
color: var(--text-muted);
font-size: 14px;
}
.report-preview-empty {
padding: 40px 24px;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.report-preview-design-btn {
margin-top: 12px;
padding: 8px 16px;
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: var(--accent-contrast);
font-size: 13px;
font-weight: 600;
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: 12px;
padding: 12px 16px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.report-preview-print-btn {
padding: 8px 24px;
background: var(--accent);
border: none;
border-radius: var(--radius-sm);
color: var(--accent-contrast);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.report-preview-print-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.report-preview-close-btn {
padding: 8px 24px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
font-weight: 600;
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,6 +1,6 @@
.scale-monitor {
min-height: 100vh;
background-color: #f5f5f7;
background-color: var(--bg);
}
.sm-body {
@@ -19,17 +19,17 @@
.sm-back {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: #666;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
}
.sm-back:hover {
border-color: #667eea;
color: #667eea;
border-color: var(--accent);
color: var(--accent);
}
.sm-conn {
@@ -42,27 +42,27 @@
}
.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;
color: var(--text-muted);
font-family: 'Courier New', monospace;
}
.sm-message {
padding: 8px 12px;
background-color: #eef0ff;
border: 1px solid #ccd;
background-color: var(--accent-soft);
border: 1px solid var(--border-strong);
border-radius: 6px;
color: #445;
color: var(--text);
font-size: 13px;
margin-bottom: 12px;
}
@@ -80,10 +80,10 @@
}
.sm-panel {
background: white;
background: var(--surface);
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 3px var(--shadow-card);
}
.sm-panel--wide {
@@ -101,7 +101,7 @@
margin: 0;
font-size: 12px;
font-weight: 600;
color: #888;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -114,16 +114,16 @@
.sm-panel-actions button {
padding: 4px 10px;
background: none;
border: 1px solid #ddd;
border: 1px solid var(--border);
border-radius: 6px;
color: #666;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.sm-panel-actions button:hover {
border-color: #667eea;
color: #667eea;
border-color: var(--accent);
color: var(--accent);
}
.sm-send {
@@ -135,7 +135,7 @@
.sm-send input {
flex: 1;
padding: 6px 10px;
border: 1px solid #ddd;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
@@ -143,10 +143,10 @@
.sm-send button {
padding: 6px 14px;
background: linear-gradient(135deg, #667eea, #764ba2);
background: linear-gradient(135deg, var(--accent), var(--accent));
border: none;
border-radius: 6px;
color: white;
color: var(--accent-contrast);
font-size: 13px;
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;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.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;
color: var(--text);
}
.sm-visible-space {
@@ -193,12 +193,12 @@
}
.sm-len {
color: #aaa;
color: var(--border-strong);
flex-shrink: 0;
}
.sm-line-hex {
color: #999;
color: var(--text-muted);
font-size: 11px;
word-break: break-all;
margin-top: 2px;
@@ -207,7 +207,7 @@
.sm-empty {
padding: 20px;
text-align: center;
color: #999;
color: var(--text-muted);
font-style: italic;
font-size: 13px;
}
@@ -230,7 +230,7 @@
gap: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -238,13 +238,13 @@
.sm-form input[type="text"],
.sm-form input[type="number"] {
padding: 7px 10px;
border: 1px solid #ddd;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
text-transform: none;
letter-spacing: normal;
font-weight: 400;
color: #333;
color: var(--text);
}
.sm-check {
@@ -254,27 +254,27 @@
text-transform: none !important;
letter-spacing: normal !important;
font-weight: 400 !important;
color: #555 !important;
color: var(--text) !important;
font-size: 12px !important;
}
.sm-hint {
font-size: 11px;
color: #999;
color: var(--text-muted);
font-style: italic;
}
.sm-preview {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #eee;
border-top: 1px solid var(--border);
}
.sm-preview h4 {
margin: 0 0 8px 0;
font-size: 11px;
font-weight: 600;
color: #888;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -289,23 +289,23 @@
font-size: 24px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: #333;
color: var(--text);
}
.sm-bal {
color: #2e7d32;
color: var(--success);
font-size: 12px;
font-weight: 600;
}
.sm-unbal {
color: #c62828;
color: var(--danger);
font-size: 12px;
font-weight: 600;
}
.sm-preview-error {
color: #c62828;
color: var(--danger);
font-size: 13px;
font-style: italic;
}
@@ -313,10 +313,10 @@
.sm-save {
margin-top: 12px;
padding: 8px 16px;
background: linear-gradient(135deg, #667eea, #764ba2);
background: linear-gradient(135deg, var(--accent), var(--accent));
border: none;
border-radius: 6px;
color: white;
color: var(--accent-contrast);
font-size: 13px;
font-weight: 600;
cursor: pointer;
@@ -324,7 +324,7 @@
.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 {
@@ -338,25 +338,25 @@
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #eee;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.sm-protocol:hover {
background-color: #f0f0ff;
background-color: var(--accent-soft);
}
.sm-protocol-name {
font-weight: 600;
color: #333;
color: var(--text);
}
.sm-protocol-kind,
.sm-protocol-poll {
font-size: 11px;
color: #999;
color: var(--text-muted);
font-family: 'Courier New', monospace;
}
@@ -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>
);
}
@@ -426,6 +426,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;
+4 -1
View File
@@ -1,3 +1,5 @@
@import './styles/tokens.css';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
@@ -5,7 +7,8 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
background-color: var(--bg);
color: var(--text);
}
code {
@@ -0,0 +1,39 @@
/**
* 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 ?? '';
}
}
const netWeight = (vehicle.tare != null && vehicle.gross != null)
? vehicle.gross - vehicle.tare
: null;
return {
vehicle: {
...vehicle,
net: vehicle.net ?? netWeight,
extra,
}
};
}
+159
View File
@@ -0,0 +1,159 @@
/**
* Design tokens — the single place every colour, radius and shadow 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 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;
/* 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);
--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;
/* 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);
}
/* 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;
}
+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).
###