added print, download as pdf. no puppeteer, we'll see
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+813
-111
File diff suppressed because it is too large
Load Diff
+277
-202
@@ -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 & 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 & 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
###
|
||||
Reference in New Issue
Block a user