diff --git a/.claude/plans/finalize-history-void.md b/.claude/plans/finalize-history-void.md new file mode 100644 index 0000000..f2a08e5 --- /dev/null +++ b/.claude/plans/finalize-history-void.md @@ -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). diff --git a/.claude/plans/main-weighing-row-five-groups.md b/.claude/plans/main-weighing-row-five-groups.md new file mode 100644 index 0000000..d65fe81 --- /dev/null +++ b/.claude/plans/main-weighing-row-five-groups.md @@ -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 +
Loading...
}>Loading concept...
}> +