# 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).