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
|
||||
Reference in New Issue
Block a user