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 +
+
Current Reading
+
+ {currentWeight !== null && !isNaN(currentWeight) + ? `${currentWeight} kg` + : (isConnected ? 'Waiting...' : 'Disconnected')} +
+ {isConnected && readingError && latestReading && ( +
{readingError}
+ )} + {canMeasure && !isFinalized && (<> + + {!licenseAllowed &&
{licenseDetail}
} + {currentUser?.can_manually_measure && ( + + )} + )} +
+``` + +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 diff --git a/.claude/plans/ui-theming.md b/.claude/plans/ui-theming.md new file mode 100644 index 0000000..690d40b --- /dev/null +++ b/.claude/plans/ui-theming.md @@ -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 ``. 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 diff --git a/backend/.claude/agent-memory/react-expert-dev/MEMORY.md b/backend/.claude/agent-memory/react-expert-dev/MEMORY.md new file mode 100644 index 0000000..06fac35 --- /dev/null +++ b/backend/.claude/agent-memory/react-expert-dev/MEMORY.md @@ -0,0 +1,2 @@ +- [SSE operation_map fallback](sse-operation-map-fallback.md) — unmapped `sse_broadcast_update` action strings pass through to the frontend verbatim as `operation`. +- [NomenclatureContext SSE dispatch structure](nomenclature-context-sse-dispatch.md) — how `handleSSEUpdate` routes object type to array key and operation to array mutation. diff --git a/backend/.claude/agent-memory/react-expert-dev/nomenclature-context-sse-dispatch.md b/backend/.claude/agent-memory/react-expert-dev/nomenclature-context-sse-dispatch.md new file mode 100644 index 0000000..33b1601 --- /dev/null +++ b/backend/.claude/agent-memory/react-expert-dev/nomenclature-context-sse-dispatch.md @@ -0,0 +1,33 @@ +--- +name: nomenclature-context-sse-dispatch +description: How handleSSEUpdate in NomenclatureContext.jsx routes an SSE message's object/operation to state mutations +metadata: + type: project +--- + +`frontend/src/contexts/NomenclatureContext.jsx`'s `handleSSEUpdate(message)`: + +1. Special-cases `object === 'nomenclature' | 'nomenclature_entry' | 'extra_combination'` + and `object === 'invoice' | 'notice'` — these just fan out to `subscribersRef` + callbacks and return early, bypassing the nomenclature-array logic entirely. +2. Otherwise maps `message.object` (the SSE `sseObjectType`, e.g. `'vehicle'`) to a + `nomenclatureKey` (e.g. `'vehicles'`) via `getNomenclatureKeyFromSSE`, which just + looks up `NOMENCLATURES_CONFIG` by `sseObjectType` — this mapping is + operation-agnostic, so it already works correctly for any new operation string + on an existing object type (no changes needed there when adding new operations). +3. Inside `setNomenclatures(prev => {...})`, an `if/else if` chain on `operation` + decides how to mutate `prev[nomenclatureKey]`: + - `'insert'`: skip if `id` already present, else `insertSorted`. + - `'update'`: `updateSorted` (removes old, re-inserts at sorted position, or + falls back to `insertSorted` if the item wasn't present at all). + - Falls through to `return prev` (no-op) for anything unhandled — this was true + even for the backend's own long-standing `'deleted'` action before this + session; there was no delete/removal handling of any kind until Phase B of + `.claude/plans/finalize-history-void.md` added `'archived'` (filter out by id) + and `'voided'` (update-in-place if present, safe no-op if not) as new + `else if` branches alongside `insert`/`update`. + +`insertSorted`/`updateSorted` (same file, ~line 62 and ~90) are the two shared +mutation helpers — always spread-based (`[...array]`, `.filter`, `.map`), never +in-place `.push`/`.splice` on the original array. Any new operation case should +reuse these two helpers rather than writing new sort/insert logic. diff --git a/backend/.claude/agent-memory/react-expert-dev/sse-operation-map-fallback.md b/backend/.claude/agent-memory/react-expert-dev/sse-operation-map-fallback.md new file mode 100644 index 0000000..d313d3b --- /dev/null +++ b/backend/.claude/agent-memory/react-expert-dev/sse-operation-map-fallback.md @@ -0,0 +1,37 @@ +--- +name: sse-operation-map-fallback +description: sse_broadcast_update's operation_map only maps created/updated/deleted; other action strings pass through unchanged to the frontend's `operation` field +metadata: + type: project +--- + +`backend/scalesapp/sse.py`'s `sse_broadcast_update(object_name, action, data)` maps +`action` through a small dict: + +```python +operation_map = { + 'created': 'insert', + 'updated': 'update', + 'deleted': 'delete' +} +... +'operation': operation_map.get(action, action), +``` + +Any action string NOT in that dict (e.g. `'archived'`, `'voided'`, added for the +vehicle finalize/void feature) is **not translated** — it arrives client-side in +`message.operation` exactly as the backend passed it, verbatim. + +**Why:** confirmed by reading the actual source during Phase B of +`.claude/plans/finalize-history-void.md` (finalize/history/void feature), rather +than assuming the plan's own hedge ("mapped through operation_map, or handled as +raw fallback — check which is simpler") was already resolved. It wasn't resolved +in the plan text; the code settles it. + +**How to apply:** when adding a new `handleSSEUpdate` case in +`frontend/src/contexts/NomenclatureContext.jsx` for a new backend action, do NOT +assume the operation string will be renamed/normalized — check `operation_map` in +`backend/scalesapp/sse.py` first, and if the action isn't in the map, write the +frontend case using the exact same string the backend passes as `action`. Do not +add entries to `operation_map` yourself for a frontend-only task — that's a +backend change and out of scope unless explicitly asked. diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 25a0fd1..e12e136 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -159,22 +159,25 @@ class VehicleSerializer(serializers.ModelSerializer): tare_user_name = serializers.CharField(source='tare_user.username', read_only=True, default=None) gross_user_name = serializers.CharField(source='gross_user.username', read_only=True, default=None) net_user_name = serializers.CharField(source='net_user.username', read_only=True, default=None) + void_user_name = serializers.CharField(source='void_user.username', read_only=True, default=None) class Meta: model = Vehicle - fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', + fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name', 'tare', 'tare_date', 'tare_user', 'tare_user_name', 'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note', 'gross', 'gross_date', 'gross_user', 'gross_user_name', 'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note', 'net', 'net_date', 'net_user', 'net_user_name', - 'doc_number', 'extra'] + 'doc_number', 'extra', + 'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason'] read_only_fields = ['id', 'tare_date', 'tare_user', 'tare_user_name', 'tare_manual', 'tare_manual_user', 'tare_manual_date', 'tare_manual_note', 'gross_date', 'gross_user', 'gross_user_name', 'gross_manual', 'gross_manual_user', 'gross_manual_date', 'gross_manual_note', 'net', 'net_date', 'net_user', 'net_user_name', - 'doc_number'] + 'doc_number', + 'is_void', 'void_date', 'void_user', 'void_user_name', 'void_reason'] def create(self, validated_data): extra_data = validated_data.pop('extra', None) @@ -208,6 +211,11 @@ class VehicleSerializer(serializers.ModelSerializer): raise serializers.ValidationError("Vehicle number cannot be empty") return value.strip() + def validate(self, attrs): + if self.instance is None and not attrs.get('driver_name'): + raise serializers.ValidationError({'driver_name': 'This field is required.'}) + return attrs + class ExtraCombinationSerializer(serializers.ModelSerializer): class Meta: diff --git a/backend/api/views.py b/backend/api/views.py index 83aff5a..786a6d6 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -228,14 +228,19 @@ class VehicleViewSet(viewsets.ModelViewSet): retrieve: Get a specific vehicle by ID update: Full update of vehicle and extra data partial_update: Partial update of vehicle - destroy: Delete a vehicle (cascades to VehicleExtra) + destroy: Disabled - vehicles are never deleted, see void() instead by_number: Get vehicle by vehicle_number measure: Record a weighing (app assigns tare vs gross by weight) + void: Flag a completed vehicle as voided (corrective reversal, not a delete) + mark_notice_printed: Signal that a completed vehicle's notice was printed/downloaded """ queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all() serializer_class = VehicleSerializer permission_classes = [IsAuthenticated] - filterset_fields = ['vehicle_number'] + filterset_fields = { + 'vehicle_number': ['exact'], + 'doc_number': ['isnull'], + } search_fields = ['vehicle_number'] ordering = ['vehicle_number'] @@ -251,10 +256,17 @@ class VehicleViewSet(viewsets.ModelViewSet): sse_broadcast_update('vehicle', 'updated', data) _record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None) - def perform_destroy(self, instance): - data = VehicleSerializer(instance).data - instance.delete() - sse_broadcast_update('vehicle', 'deleted', data) + def destroy(self, request, *args, **kwargs): + # Vehicles are never deleted - a completed weighing is corrected with + # /void/ instead, which keeps the record (and its doc_number) intact + # and visible in History. Overriding destroy(), not perform_destroy(): + # ModelViewSet.destroy() calls perform_destroy() then always returns + # 204, so neutering only perform_destroy would silently no-op instead + # of telling the caller this action doesn't exist. + return Response( + {'detail': 'Vehicles cannot be deleted. Use /void/ on a completed vehicle instead.'}, + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) @action(detail=False, methods=['get'], url_path='by-number') def by_number(self, request): @@ -321,6 +333,53 @@ class VehicleViewSet(viewsets.ModelViewSet): sse_broadcast_update('vehicle', 'updated', serializer.data) return Response(serializer.data) + @action(detail=True, methods=['post'], url_path='void') + def void(self, request, pk=None): + """Flag a completed vehicle as voided. Does not touch doc_number/net/tare/gross.""" + if not request.user.can_edit_documents: + return Response({'detail': 'You do not have permission to void a document.'}, + status=status.HTTP_403_FORBIDDEN) + + vehicle = self.get_object() + if not vehicle.doc_number: + return Response({'detail': 'Only a completed vehicle can be voided.'}, + status=status.HTTP_400_BAD_REQUEST) + + reason = (request.data.get('reason') or '').strip() + if not reason: + return Response({'detail': 'A reason is required to void a document.'}, + status=status.HTTP_400_BAD_REQUEST) + + vehicle.is_void = True + vehicle.void_date = timezone.now() + vehicle.void_user = request.user + vehicle.void_reason = reason + vehicle.save() + + serializer = self.get_serializer(vehicle) + sse_broadcast_update('vehicle', 'voided', serializer.data) + return Response(serializer.data) + + @action(detail=True, methods=['post'], url_path='mark-notice-printed') + def mark_notice_printed(self, request, pk=None): + """Signal that the operator has acted on a completed vehicle's notice (print/download). + + Makes no DB change - doc_number already marks the vehicle as finalized. + This only broadcasts the SSE event that removes it from the active list. + """ + if not request.user.can_measure: + return Response({'detail': 'You do not have permission to do this.'}, + status=status.HTTP_403_FORBIDDEN) + + vehicle = self.get_object() + if not vehicle.doc_number: + return Response({'detail': 'Only a completed vehicle can be archived.'}, + status=status.HTTP_400_BAD_REQUEST) + + serializer = self.get_serializer(vehicle) + sse_broadcast_update('vehicle', 'archived', serializer.data) + return Response(serializer.data) + class ExtraCombinationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, diff --git a/backend/vehicles/migrations/0007_vehicle_driver_name.py b/backend/vehicles/migrations/0007_vehicle_driver_name.py new file mode 100644 index 0000000..c043eb6 --- /dev/null +++ b/backend/vehicles/migrations/0007_vehicle_driver_name.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.1 on 2026-09-21 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "vehicles", + "0006_vehicle_gross_manual_date_vehicle_gross_manual_note_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="vehicle", + name="driver_name", + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/backend/vehicles/migrations/0008_vehicle_is_void_vehicle_void_date_and_more.py b/backend/vehicles/migrations/0008_vehicle_is_void_vehicle_void_date_and_more.py new file mode 100644 index 0000000..001779d --- /dev/null +++ b/backend/vehicles/migrations/0008_vehicle_is_void_vehicle_void_date_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 6.0.1 on 2026-09-22 17:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vehicles", "0007_vehicle_driver_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="vehicle", + name="is_void", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="vehicle", + name="void_date", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="vehicle", + name="void_reason", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="vehicle", + name="void_user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_vehicle_void", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="vehicle", + name="vehicle_number", + field=models.CharField(max_length=15), + ), + migrations.AddConstraint( + model_name="vehicle", + constraint=models.UniqueConstraint( + condition=models.Q(("doc_number__isnull", True)), + fields=("vehicle_number",), + name="unique_active_vehicle_number", + ), + ), + ] diff --git a/backend/vehicles/models.py b/backend/vehicles/models.py index 7e77445..de39a9a 100644 --- a/backend/vehicles/models.py +++ b/backend/vehicles/models.py @@ -8,11 +8,12 @@ from api.models import User # Create your models here. class Vehicle(models.Model): - vehicle_number = models.CharField(max_length=15, unique=True) + vehicle_number = models.CharField(max_length=15) trailer1_number = models.CharField(max_length=15, null=True, blank=True) trailer2_number = models.CharField(max_length=15, null=True, blank=True) driver_pid = models.CharField(max_length=50, null=True, blank=True) - + driver_name = models.CharField(max_length=50, null=True, blank=True) + tare = models.IntegerField(null=True, blank=True) tare_date = models.DateTimeField(null=True, blank=True) tare_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_tare', on_delete=models.SET_NULL) @@ -35,7 +36,19 @@ class Vehicle(models.Model): doc_number = models.CharField(max_length=20, null=True, blank=True, unique=True) + is_void = models.BooleanField(default=False) + void_date = models.DateTimeField(null=True, blank=True) + void_user = models.ForeignKey(User, null=True, blank=True, related_name='user_vehicle_void', on_delete=models.SET_NULL) + void_reason = models.CharField(max_length=255, null=True, blank=True) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['vehicle_number'], + condition=models.Q(doc_number__isnull=True), + name='unique_active_vehicle_number', + ), + ] def __str__(self): return f"{self.vehicle_number}" diff --git a/backend/vehicles/serializers.py b/backend/vehicles/serializers.py index aacb85e..2c4fd50 100644 --- a/backend/vehicles/serializers.py +++ b/backend/vehicles/serializers.py @@ -4,4 +4,10 @@ from .models import Vehicle class VehicleSerializer(serializers.ModelSerializer): class Meta: model = Vehicle - fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user'] \ No newline at end of file + fields = ['id', 'vehicle_number', 'trailer1_number', 'trailer2_number', 'driver_pid', 'driver_name', 'tare', 'tare_date', 'tare_user', 'gross', 'gross_date', 'gross_user'] + + def validate(self, attrs): + """Require driver_name on creation only; existing vehicles may have it as NULL.""" + if self.instance is None and not attrs.get('driver_name'): + raise serializers.ValidationError({'driver_name': 'This field is required.'}) + return attrs \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index d84b45d..e913343 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,33 +1,33 @@ .App { min-height: 100vh; - background-color: #f5f5f5; + background-color: var(--bg); display: flex; flex-direction: column; } .status-bar { - background: white; + background: var(--surface); padding: 12px 20px; display: flex; align-items: center; gap: 20px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: var(--shadow-card); } .status-connected { - color: #4CAF50; + color: var(--success); font-size: 14px; font-weight: 500; } .status-disconnected { - color: #f44336; + color: var(--danger); font-size: 14px; font-weight: 500; } .status-error { - color: #f44336; + color: var(--danger); font-size: 12px; } @@ -40,19 +40,19 @@ } .error-message { - background-color: #f8d7da; - color: #721c24; + background-color: var(--danger-soft); + color: var(--danger); padding: 12px 20px; border-radius: 4px; margin-bottom: 20px; - border: 1px solid #f5c6cb; + border: 1px solid var(--danger); } .loading { text-align: center; padding: 40px; font-size: 18px; - color: #666; + color: var(--text-muted); } @media (max-width: 768px) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e66751f..5d0279d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,11 +1,13 @@ import React, { useState, Suspense, lazy } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; +import { ThemeProvider } from './contexts/ThemeContext'; // import ProtectedRoute from './components/ProtectedRoute'; import Login from './components/Users/Login'; import Main from './components/Main'; import ReportEditor from './components/ReportEditor/ReportEditor'; import UserManager from './components/Users/UserManager'; +import History from './components/History'; import './App.css'; import { NomenclatureProvider } from './contexts/NomenclatureContext'; import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext'; @@ -16,6 +18,13 @@ import LicenseManager from './components/License/LicenseManager'; const A4ReportEditor = lazy(() => import('./components/ReportEditor/a4/A4ReportEditor')); +// UI concept preview — a standalone mockup at /concept/cyberpunk, deliberately +// OUTSIDE the auth gate and the providers. It ships its own :root variables and +// styles body/*, which would fight the app's theme tokens if it were ever +// rendered alongside the real UI; lazy-loading keeps its CSS out of the bundle +// until that route is opened. Nothing in the app imports it. +const ScalesAppCyberpunk = lazy(() => import('./components/concepts/ScalesAppCyberpunk')); + // function MainApp() { // const [selectedPort, setSelectedPort] = useState(null); // const { readings, isConnected, error } = useSerialData(); @@ -69,6 +78,7 @@ function AppContent() {

Loading...

}> } /> + } /> : } /> : } /> : } /> @@ -87,9 +97,28 @@ function AppContent() { function App() { return ( - - - + + {/* Concept preview, no auth and no providers — see the note above. */} +

Loading concept...

}> + +
+ } + /> + + + + + + } + /> + ); } diff --git a/frontend/src/assets/bar2.png b/frontend/src/assets/bar2.png new file mode 100644 index 0000000..2fd974e Binary files /dev/null and b/frontend/src/assets/bar2.png differ diff --git a/frontend/src/assets/weigh-station-banner.jpg b/frontend/src/assets/weigh-station-banner.jpg new file mode 100644 index 0000000..0b8d20a Binary files /dev/null and b/frontend/src/assets/weigh-station-banner.jpg differ diff --git a/frontend/src/assets/weigh-station-hero.jpg b/frontend/src/assets/weigh-station-hero.jpg new file mode 100644 index 0000000..e3e6ded Binary files /dev/null and b/frontend/src/assets/weigh-station-hero.jpg differ diff --git a/frontend/src/components/ExtraCombinationsTable.css b/frontend/src/components/ExtraCombinationsTable.css index 41d68c0..8eeddef 100644 --- a/frontend/src/components/ExtraCombinationsTable.css +++ b/frontend/src/components/ExtraCombinationsTable.css @@ -5,7 +5,7 @@ .extra-combos h3 { margin: 0 0 8px 0; font-size: 12px; - color: #888; + color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; @@ -13,9 +13,9 @@ .extra-combos-error { padding: 6px 10px; - background-color: #fee; - color: #c33; - border: 1px solid #fcc; + background-color: var(--danger-soft); + color: var(--danger); + border: 1px solid var(--danger); border-radius: 6px; font-size: 12px; margin-bottom: 8px; @@ -25,7 +25,7 @@ max-height: 180px; overflow-y: auto; overflow-x: auto; - border: 1px solid #e0e0e0; + border: 1px solid var(--border); border-radius: 6px; } @@ -35,17 +35,17 @@ } .extra-combos-table-container::-webkit-scrollbar-track { - background: #f1f1f1; + background: var(--surface-hover); border-radius: 4px; } .extra-combos-table-container::-webkit-scrollbar-thumb { - background: #888; + background: var(--text-muted); border-radius: 4px; } .extra-combos-table-container::-webkit-scrollbar-thumb:hover { - background: #555; + background: var(--text-muted); } .extra-combos-table { @@ -54,7 +54,7 @@ } .extra-combos-table thead { - background-color: #f8f9fa; + background-color: var(--surface-sunken); position: sticky; top: 0; z-index: 1; @@ -64,9 +64,9 @@ padding: 0.4rem 0.75rem; text-align: left; font-weight: 600; - color: #495057; + color: var(--text-muted); font-size: 0.75rem; - border-bottom: 2px solid #dee2e6; + border-bottom: 2px solid var(--border); white-space: nowrap; text-transform: uppercase; letter-spacing: 0.3px; @@ -78,9 +78,9 @@ .extra-combos-table td { padding: 0.4rem 0.75rem; - border-bottom: 1px solid #dee2e6; + border-bottom: 1px solid var(--border); font-size: 0.85rem; - color: #333; + color: var(--text); white-space: nowrap; } @@ -90,7 +90,7 @@ } .extra-combos-row:hover { - background-color: #f0f0ff; + background-color: var(--accent-soft); } .extra-combos-td-actions { @@ -99,31 +99,31 @@ .extra-combos-delete-btn { background: none; - border: 1px solid #dee2e6; + border: 1px solid var(--border); padding: 0 0.35rem; border-radius: 4px; cursor: pointer; transition: all 0.2s; font-size: 0.9rem; line-height: 1.4; - color: #999; + color: var(--text-muted); } .extra-combos-delete-btn:hover { - background-color: #dc3545; - border-color: #dc3545; - color: white; + background-color: var(--danger); + border-color: var(--danger); + color: var(--accent-contrast); } .extra-combos-stale { - color: #999; + color: var(--text-muted); font-style: italic; } .extra-combos-empty { text-align: center; padding: 1rem !important; - color: #999; + color: var(--text-muted); font-style: italic; font-size: 0.85rem; } diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css index 202a266..d77cff1 100644 --- a/frontend/src/components/Header.css +++ b/frontend/src/components/Header.css @@ -1,8 +1,8 @@ .app-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + background: var(--header-bg); + color: var(--header-text); padding: 0; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow-raised); position: sticky; top: 0; z-index: 100; @@ -32,10 +32,10 @@ .nav-button { width: 42px; height: 42px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.3); - color: white; + border-radius: var(--radius); + background: var(--header-chrome); + border: 1px solid var(--header-chrome-border); + color: var(--header-text); font-size: 20px; cursor: pointer; transition: background 0.2s, transform 0.2s; @@ -46,7 +46,7 @@ } .nav-button:hover { - background: rgba(255, 255, 255, 0.3); + background: var(--header-chrome-hover); transform: scale(1.05); } @@ -70,9 +70,9 @@ left: 0; z-index: 101; min-width: 220px; - background: #fff; - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow-overlay); overflow: hidden; } @@ -84,16 +84,16 @@ border: none; text-align: left; cursor: pointer; - color: #333; + color: var(--text); font-family: inherit; } .nav-dropdown-menu button + button { - border-top: 1px solid #eee; + border-top: 1px solid var(--border); } .nav-dropdown-menu button:hover { - background: #f2f3fa; + background: var(--surface-hover); } .nav-dropdown-menu strong { @@ -106,7 +106,7 @@ display: block; margin-top: 2px; font-size: 11px; - color: #777; + color: var(--text-muted); } .header-right { @@ -142,8 +142,8 @@ width: 42px; height: 42px; border-radius: 50%; - background: white; - color: #667eea; + background: var(--header-avatar-bg); + color: var(--header-avatar-text); display: flex; align-items: center; justify-content: center; @@ -151,19 +151,19 @@ font-size: 16px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + box-shadow: var(--shadow-card); } .avatar:hover { transform: scale(1.05); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + box-shadow: var(--shadow-raised); } .logout-button { padding: 8px 16px; - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.3); - color: white; + background: var(--header-chrome); + border: 1px solid var(--header-chrome-border); + color: var(--header-text); border-radius: 6px; font-size: 14px; font-weight: 500; @@ -172,7 +172,7 @@ } .logout-button:hover { - background: rgba(255, 255, 255, 0.3); + background: var(--header-chrome-hover); } @media (max-width: 768px) { @@ -196,3 +196,28 @@ margin-right: 0; } } + +/* Theme toggle — same chrome as the nav buttons, so it reads as part of the + header furniture rather than a feature being advertised. */ +.theme-toggle { + width: 38px; + height: 38px; + border-radius: var(--radius-sm); + background: var(--header-chrome); + border: 1px solid var(--header-chrome-border); + color: var(--header-text); + font-size: 17px; + line-height: 1; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + flex-shrink: 0; +} + +.theme-toggle:hover { + background: var(--header-chrome-hover); + transform: scale(1.05); +} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 9a4e82c..0975f6a 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -1,12 +1,14 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; +import { useTheme } from '../contexts/ThemeContext'; import ChangePasswordOverlay from './Users/ChangePasswordOverlay'; import NomenclatureManager from './NomenclatureManager/NomenclatureManager'; import './Header.css'; function Header() { const { currentUser, logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); const navigate = useNavigate(); const [showPasswordOverlay, setShowPasswordOverlay] = useState(false); const [showNomenclatureManager, setShowNomenclatureManager] = useState(false); @@ -74,6 +76,13 @@ function Header() { 📋 )} + {currentUser?.is_admin && (
{currentUser?.username} ({currentUser?.role}) diff --git a/frontend/src/components/History.css b/frontend/src/components/History.css new file mode 100644 index 0000000..7f4d961 --- /dev/null +++ b/frontend/src/components/History.css @@ -0,0 +1,182 @@ +.history { + max-width: 1200px; + margin: 0 auto; + padding: 24px 20px; +} + +.history-title { + margin: 0 0 16px 0; + color: var(--text); +} + +.history-error { + padding: 10px 14px; + margin-bottom: 12px; + background: var(--danger-soft); + color: var(--danger); + border-radius: var(--radius-sm); + font-size: 13px; +} + +.history-table-wrap { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.history-table th { + text-align: left; + padding: 10px 14px; + background: var(--surface-hover); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); +} + +.history-table td { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + color: var(--text); +} + +.history-row { + cursor: pointer; +} + +.history-row:hover { + background: var(--surface-hover); +} + +.history-row:last-child td { + border-bottom: none; +} + +.history-empty { + text-align: center; + color: var(--text-muted); + padding: 32px !important; +} + +.history-badge { + display: inline-block; + padding: 3px 10px; + border: 1px solid; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.history-badge--ok { + color: var(--success); + border-color: var(--success); + background: var(--success-soft); +} + +.history-badge--void { + color: var(--danger); + border-color: var(--danger); + background: var(--danger-soft); +} + +.history-void-btn { + padding: 4px 12px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; +} + +.history-void-btn:hover { + border-color: var(--danger); + color: var(--danger); +} + +.history-void-inline { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 180px; +} + +.history-void-inline input { + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 12px; + box-sizing: border-box; +} + +.history-void-error { + color: var(--danger); + font-size: 11px; +} + +.history-void-actions { + display: flex; + gap: 6px; +} + +.history-void-actions button { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-hover); + color: var(--text); + font-size: 12px; + cursor: pointer; +} + +.history-pager { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 16px; +} + +.history-pager button { + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font-size: 13px; + cursor: pointer; +} + +.history-pager button:hover:not(:disabled) { + border-color: var(--border-strong); +} + +.history-pager button:disabled { + cursor: default; + opacity: 0.5; +} + +.history-pager .history-page-btn--active { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); + opacity: 1; +} + +.history-page-gap { + color: var(--text-muted); + padding: 0 4px; +} diff --git a/frontend/src/components/History.jsx b/frontend/src/components/History.jsx new file mode 100644 index 0000000..9d162e8 --- /dev/null +++ b/frontend/src/components/History.jsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from 'react'; +import Header from './Header'; +import { useAuth } from '../contexts/AuthContext'; +import { useNomenclatureData } from '../contexts/NomenclatureDataContext'; +import { buildVehicleReportData } from '../services/vehicleReportData'; +import ReportPreviewModal from './ReportPreviewModal'; +import api from '../services/api'; +import './History.css'; + +const PAGE_SIZE = 100; // matches the backend's DRF PAGE_SIZE + +export default function History() { + const { currentUser } = useAuth(); + const { definitions, entries } = useNomenclatureData(); + + const [vehicles, setVehicles] = useState([]); + const [count, setCount] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + // django-filter's OrderingFilter has no explicit `ordering_fields` set on + // VehicleViewSet, so it falls back to allowing any non-write-only + // serializer field - net_date included - meaning ?ordering=-net_date + // works server-side today. No client-side fallback sort is needed. + const [previewVehicle, setPreviewVehicle] = useState(null); + const [voidingId, setVoidingId] = useState(null); + const [voidReason, setVoidReason] = useState(''); + const [voidError, setVoidError] = useState(''); + const [isVoiding, setIsVoiding] = useState(false); + + const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); + + // Windowed page list (first, last, and a range around the current page) + // so the pager stays usable once History accumulates hundreds of pages - + // nothing in this feature ever deletes a vehicle, so unbounded growth is + // the expected long-run case, not an edge case. + const pageList = useMemo(() => { + const spread = 2; + const pages = new Set([1, totalPages]); + for (let n = page - spread; n <= page + spread; n++) { + if (n >= 1 && n <= totalPages) pages.add(n); + } + const sorted = [...pages].sort((a, b) => a - b); + const withGaps = []; + sorted.forEach((n, i) => { + if (i > 0 && n - sorted[i - 1] > 1) withGaps.push('...'); + withGaps.push(n); + }); + return withGaps; + }, [page, totalPages]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(''); + api.get('/api/vehicles/', { + params: { 'doc_number__isnull': false, ordering: '-net_date', page }, + }) + .then(res => { + if (cancelled) return; + setVehicles(res.data.results || []); + setCount(res.data.count || 0); + }) + .catch(err => { + if (cancelled) return; + setError(err.response?.data?.detail || 'Failed to load history'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [page]); + + const formatDate = (dateStr) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const openPreview = (vehicle) => { + setPreviewVehicle(vehicle); + }; + + const previewData = useMemo( + () => previewVehicle ? buildVehicleReportData(previewVehicle, definitions, entries) : null, + [previewVehicle, definitions, entries] + ); + + const startVoid = (vehicle) => { + setVoidingId(vehicle.id); + setVoidReason(''); + setVoidError(''); + }; + + const cancelVoid = () => { + setVoidingId(null); + setVoidReason(''); + setVoidError(''); + }; + + const confirmVoid = async () => { + if (!voidReason.trim()) { + setVoidError('A reason is required to void a document.'); + return; + } + if (!window.confirm('Void this document? This flags it permanently and cannot be undone casually.')) { + return; + } + setIsVoiding(true); + setVoidError(''); + try { + const res = await api.post(`/api/vehicles/${voidingId}/void/`, { reason: voidReason.trim() }); + // History has its own separate fetch/state (not NomenclatureContext's + // vehicles array), so the voided row is updated in place here from + // the API response rather than relying on the SSE 'voided' handler. + setVehicles(prev => prev.map(v => v.id === res.data.id ? res.data : v)); + cancelVoid(); + } catch (err) { + setVoidError(err.response?.data?.detail || 'Failed to void document'); + } finally { + setIsVoiding(false); + } + }; + + return ( +
+
+
+

History

+ + {error &&
{error}
} + +
+ + + + + + + + + + + + + {loading ? ( + + ) : vehicles.length === 0 ? ( + + ) : ( + vehicles.map(v => ( + openPreview(v)}> + + + + + + + + )) + )} + +
VehicleDoc #Net (kg)DateStatus
Loading...
No completed vehicles yet
{v.vehicle_number}{v.doc_number}{v.net != null ? v.net : '—'}{formatDate(v.net_date)} + + {v.is_void ? 'Voided' : 'Completed'} + + e.stopPropagation()}> + {currentUser?.can_edit_documents && !v.is_void && ( + voidingId === v.id ? ( +
+ setVoidReason(e.target.value)} + autoFocus + /> + {voidError &&
{voidError}
} +
+ + +
+
+ ) : ( + + ) + )} +
+
+ + {totalPages > 1 && ( +
+ + {pageList.map((n, i) => ( + n === '...' ? ( + … + ) : ( + + ) + ))} + +
+ )} +
+ + {previewData && ( + setPreviewVehicle(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/components/Main.css b/frontend/src/components/Main.css index 184ece8..9a4b4be 100644 --- a/frontend/src/components/Main.css +++ b/frontend/src/components/Main.css @@ -7,10 +7,10 @@ /* Left panel - vehicle list */ .main-left { width: 260px; - border-right: 1px solid #e0e0e0; + border-right: 1px solid var(--border); display: flex; flex-direction: column; - background: #fafafa; + background: var(--surface-sunken); flex-shrink: 0; } @@ -19,13 +19,13 @@ display: flex; flex-direction: column; gap: 8px; - border-bottom: 1px solid #e0e0e0; + border-bottom: 1px solid var(--border); } .vehicle-add-btn { padding: 8px 16px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + background: var(--accent-gradient); + color: var(--accent-contrast); border: none; border-radius: 6px; font-size: 13px; @@ -36,19 +36,19 @@ .vehicle-add-btn:hover { transform: translateY(-1px); - box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); + box-shadow: 0 3px 8px var(--accent-soft); } .vehicle-search { padding: 7px 10px; - border: 1px solid #ddd; + border: 1px solid var(--border); border-radius: 6px; font-size: 13px; } .vehicle-search:focus { outline: none; - border-color: #667eea; + border-color: var(--accent); } /* Vehicle list */ @@ -63,34 +63,34 @@ align-items: center; padding: 10px 12px; cursor: pointer; - border-bottom: 1px solid #f0f0f0; + border-bottom: 1px solid var(--surface-hover); transition: background 0.15s; } .vehicle-list-item:hover { - background: #f0f0ff; + background: var(--accent-soft); } .vehicle-list-item--active { - background: #e8eaff; - border-left: 3px solid #667eea; + background: var(--accent-soft); + border-left: 3px solid var(--accent); } .vehicle-list-number { font-weight: 600; font-size: 14px; - color: #333; + color: var(--text); } .vehicle-list-weight { font-size: 12px; - color: #888; + color: var(--text-muted); } .vehicle-list-empty { padding: 20px; text-align: center; - color: #999; + color: var(--text-muted); font-size: 13px; } @@ -98,7 +98,7 @@ .main-right { flex: 1; overflow-y: auto; - background: white; + background: var(--surface); } .main-placeholder { @@ -106,121 +106,311 @@ align-items: center; justify-content: center; height: 100%; - color: #999; + color: var(--text-muted); font-size: 16px; } +/* No max-width: four readouts on one row do not fit in the old 700px cap. */ .vehicle-detail { padding: 24px; - max-width: 700px; } +/* Also used standalone by the "New Vehicle" form heading, which has no icon, + pills or photo - hence the margin/weight living here rather than only on + .vehicle-heading. Inside .vehicle-hero the margin is overridden to 0. */ .vehicle-detail-title { margin: 0 0 20px 0; - font-size: 24px; - color: #333; + font-size: 18px; + color: var(--text); font-weight: 700; } +/* Header bar: icon, plate number, status pills, and (cockpit only) a photo + bled to the right edge. Structure here is theme-agnostic; only the skin + (border colour, glow, the photo image) is cockpit-specific, in the section + below. */ +.vehicle-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 108px; + margin-bottom: 20px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} + +.vehicle-heading { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex-shrink: 0; +} + +/* Matches the Current Reading value's size AND glow - the two biggest + numbers on the screen, the vehicle being weighed and the weight doing the + weighing, given the same visual weight. Reuses --reading/--reading-glow + directly rather than duplicating their values, so a future retune of the + reading's look carries over here too. */ +.vehicle-heading .vehicle-detail-title { + margin: 0; + font-size: 40px; + color: var(--reading); + text-shadow: var(--reading-glow); +} + +.vehicle-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + background: var(--surface-hover); +} + +.vehicle-status { + flex-shrink: 0; + padding: 4px 10px; + border: 1px solid; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + letter-spacing: .03em; + white-space: nowrap; +} +.vehicle-status--progress { + color: var(--warning); + border-color: var(--warning); + background: var(--warning-soft); +} +.vehicle-status--done { + color: var(--success); + border-color: var(--success); + background: var(--success-soft); +} +.vehicle-status--scale { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); +} + +/* Hidden in classic (no photo asset for this theme). Cockpit turns it on and + gives it a fixed width - see the cockpit section below. */ +.vehicle-hero-photo { + display: none; + height: 100%; +} + .vehicle-error { - background-color: #fee; - color: #c33; + background-color: var(--danger-soft); + color: var(--danger); padding: 10px 14px; border-radius: 6px; - border: 1px solid #fcc; + border: 1px solid var(--danger); font-size: 13px; margin-bottom: 14px; } -/* Current reading */ -.reading-display { - margin-bottom: 20px; - padding: 16px; - background: #f8f8fc; - border-radius: 8px; - border: 1px solid #e0e0e0; -} - -.reading-display label { - display: block; - font-size: 12px; - font-weight: 600; - color: #888; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; -} - -.reading-value { - font-size: 32px; - font-weight: 700; - color: #667eea; - font-family: 'Courier New', monospace; -} - -.reading-value--disconnected { - color: #ccc; - font-size: 18px; -} - -/* Weighing cards */ +/* Weighing cards. A fixed five-column grid - four readouts and an action + column: the readouts must not move or resize as a weighing progresses, so + the column count never changes and the empty slots render an em-dash rather + than disappearing. The buttons are grouped in the last column instead of + sitting inside a readout, which keeps the cards a uniform height. */ .weighing-section { - display: flex; + display: grid; + grid-template-columns: 1.8fr 0.85fr 0.85fr 0.85fr 0.85fr; gap: 16px; margin-bottom: 24px; - flex-wrap: wrap; + align-items: stretch; } +/* align-items: flex-start, not center: Tare/Gross carry a meta line (date, + user) that Net never has, so centering would sit their labels/values at + different heights depending on how many lines follow. Top-aligning keeps + the label the same height across every card in the row regardless of what + comes after it. */ .weight-card { - flex: 1; - min-width: 160px; + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; padding: 16px; - border: 1px solid #e0e0e0; + border: 1px solid var(--border); border-radius: 8px; - background: white; + background: var(--surface); +} + +/* Icon column: empty in classic (no icon glyph defined there), so it takes no + space and the content column starts flush against the card's padding. */ +.weight-card-icon { + flex: none; +} + +.weight-card-content { + min-width: 0; + flex: 1; +} + +/* Measure/Manual entry, right-aligned in the Current Reading card next to its + value - not stretched, so it keeps its natural button width. */ +.weight-card-reading-actions { + flex: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.weight-card-reading-actions .weight-set-btn, +.weight-card-reading-actions .weight-manual-btn { + width: auto; + margin-top: 0; +} + +/* The action column. It carries .weight-card too, so it gets the same border, + padding and background as the four readouts - consistent panelling across + the row. This block only adds the button layout on top: centred so a single + button does not float at the top of a tall row. */ +.weight-actions { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} + +/* The buttons carry a stacking margin for when they sit inside a card; in this + column the flex gap does that job. */ +.weight-actions > .weight-set-btn, +.weight-actions > .weight-manual-btn, +.weight-actions > .design-notice-split { + margin-top: 0; +} + +/* Equalizer: 40 bars, one per recent live reading, bottom-aligned so height + differences read the way an audio equalizer's do. Lives directly in the + hero bar between the status pills and the photo - no card chrome (border, + background, padding) of its own, just the bars and their toggle. */ +.vehicle-hero-equalizer { + position: relative; + flex: 1; + min-width: 0; + height: 100%; + padding: 12px 0; + box-sizing: border-box; +} + +/* Small corner toggle for the equalizer's scale mode. Absolutely positioned + over the bars rather than taking its own row, so it doesn't shrink the + card's already-tight vertical space. */ +.equalizer-scale-toggle { + position: absolute; + top: 6px; + right: 6px; + z-index: 1; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface-hover); + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: .04em; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} + +.equalizer-scale-toggle:hover { + border-color: var(--accent); + color: var(--accent); +} + +.equalizer-bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + width: 100%; + height: 100%; +} + +/* Fixed narrow width rather than flex:1 - the bars stay thin and evenly + spaced (space-between on the row) instead of stretching to fill whatever + gap is left over. */ +.equalizer-bar { + flex: 0 0 3px; + background: var(--border-strong); + border-radius: 2px 2px 0 0; + transition: height 0.15s ease-out; +} + +.equalizer-bar--balanced { + background: var(--success); +} + +.equalizer-bar--unbalanced { + background: var(--danger); } .weight-card--net { - background: #f0fff0; - border-color: #b2dfb2; + background: var(--success-soft); + border-color: var(--success); } .weight-card-header { font-size: 12px; font-weight: 600; - color: #888; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } .weight-card--net .weight-card-header { - color: #2e7d32; + color: var(--success); } .weight-card-value { font-size: 22px; font-weight: 700; - color: #333; + color: var(--text); margin-bottom: 4px; font-family: 'Courier New', monospace; } .weight-card--net .weight-card-value { - color: #2e7d32; + color: var(--success); +} + +/* The live reading is the instrument on this screen, so it is set larger than + the recorded weights beside it. */ +.weight-card-value--reading { + font-size: 40px; + color: var(--reading); + text-shadow: var(--reading-glow); +} + +.reading-value--disconnected { + color: var(--border-strong); + font-size: 18px; } .weight-card-meta { font-size: 11px; - color: #999; + color: var(--text-muted); margin-bottom: 10px; } .weight-set-btn { width: 100%; padding: 8px 12px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + background: var(--accent-gradient); + color: var(--accent-contrast); border: none; border-radius: 6px; font-size: 13px; @@ -232,7 +422,7 @@ .weight-set-btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); + box-shadow: 0 3px 8px var(--accent-soft); } .weight-set-btn:disabled { @@ -281,10 +471,10 @@ right: 0; left: 0; z-index: 21; - background: #fff; - border: 1px solid #d8d8e0; + background: var(--surface); + border: 1px solid var(--border); border-radius: 6px; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); + box-shadow: 0 6px 20px var(--shadow-overlay); overflow: hidden; } @@ -297,15 +487,15 @@ text-align: left; cursor: pointer; font-size: 12px; - color: #333; + color: var(--text); } .design-notice-menu button + button { - border-top: 1px solid #eee; + border-top: 1px solid var(--border); } .design-notice-menu button:hover { - background: #f2f3fa; + background: var(--surface-hover); } .design-notice-menu strong { @@ -318,19 +508,19 @@ display: block; margin-top: 1px; font-size: 11px; - color: #777; + color: var(--text-muted); } /* Extra fields section */ .extra-section { - border-top: 1px solid #e0e0e0; + border-top: 1px solid var(--border); padding-top: 20px; } .extra-section h3 { margin: 0 0 14px 0; font-size: 14px; - color: #667eea; + color: var(--accent); font-weight: 600; } @@ -341,6 +531,13 @@ margin-bottom: 14px; } +.vehicle-new-fields { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1.6fr; + gap: 12px; + margin-bottom: 14px; +} + .extra-field { display: flex; flex-direction: column; @@ -350,24 +547,29 @@ .extra-field label { font-size: 12px; font-weight: 600; - color: #555; + color: var(--text); text-transform: uppercase; letter-spacing: 0.5px; } +/* Background and colour are explicit: without them these controls fall back to + the UA defaults and only stay readable in cockpit by accident of + `color-scheme: dark`. */ .extra-field input[type="text"], .extra-field input[type="number"], .extra-field select { padding: 7px 10px; - border: 1px solid #ddd; + border: 1px solid var(--border); border-radius: 6px; font-size: 14px; + background: var(--surface); + color: var(--text); } .extra-field input:focus, .extra-field select:focus { outline: none; - border-color: #667eea; + border-color: var(--accent); } .extra-field input[type="checkbox"] { @@ -378,8 +580,8 @@ .extra-save-btn { padding: 8px 20px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + background: var(--accent-gradient); + color: var(--accent-contrast); border: none; border-radius: 6px; font-size: 13px; @@ -390,7 +592,7 @@ .extra-save-btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); + box-shadow: 0 3px 8px var(--accent-soft); } .extra-save-btn:disabled { @@ -406,16 +608,17 @@ .vehicle-form-actions { display: flex; gap: 10px; + justify-content: flex-end; margin-top: 20px; padding-top: 16px; - border-top: 1px solid #e0e0e0; + border-top: 1px solid var(--border); } .vehicle-form-cancel { padding: 8px 20px; - background: white; - color: #666; - border: 1px solid #ddd; + background: var(--surface); + color: var(--text-muted); + border: 1px solid var(--border); border-radius: 6px; font-size: 13px; font-weight: 600; @@ -424,8 +627,8 @@ } .vehicle-form-cancel:hover:not(:disabled) { - background: #f5f5f5; - border-color: #ccc; + background: var(--bg); + border-color: var(--border-strong); } .vehicle-form-cancel:disabled { @@ -444,24 +647,57 @@ width: 100%; max-height: 250px; border-right: none; - border-bottom: 1px solid #e0e0e0; - } - - .weighing-section { - flex-direction: column; + border-bottom: 1px solid var(--border); } .extra-fields { grid-template-columns: 1fr; } + + .vehicle-new-fields { + grid-template-columns: 1fr; + } +} + +/* The weighing row collapses on its own thresholds, not the panel's: under + ~1150px four readouts get crushed, under 620px even two do. These apply to + both themes - the grid is theme-agnostic now. + + Once the readouts wrap, the action column spans the full width and lays its + buttons out in a row: a narrow column of stacked buttons beside a 2x2 block + of readouts wastes the width it has. */ +@media (max-width: 1150px) { + .weighing-section { + grid-template-columns: 1fr 1fr; + } + + .weight-actions { + grid-column: 1 / -1; + flex-direction: row; + align-items: center; + } + + .weight-actions > * { + flex: 1; + } +} + +@media (max-width: 620px) { + .weighing-section { + grid-template-columns: 1fr; + } + + .weight-actions { + flex-direction: column; + } } .weight-manual-tag { margin-left: 6px; padding: 1px 5px; border-radius: 3px; - background-color: #fff3cd; - color: #856404; + background-color: var(--warning-soft); + color: var(--warning); font-size: 10px; font-weight: 600; text-transform: uppercase; @@ -472,17 +708,17 @@ margin-top: 6px; padding: 4px 10px; background: none; - border: 1px solid #ddd; + border: 1px solid var(--border); border-radius: 6px; - color: #666; + color: var(--text-muted); font-size: 12px; cursor: pointer; transition: all 0.2s; } .weight-manual-btn:hover { - border-color: #667eea; - color: #667eea; + border-color: var(--accent); + color: var(--accent); } .manual-overlay { @@ -499,7 +735,7 @@ } .manual-content { - background: white; + background: var(--surface); border-radius: 12px; padding: 24px; width: 340px; @@ -509,7 +745,7 @@ .manual-content h3 { margin: 0 0 16px 0; font-size: 16px; - color: #333; + color: var(--text); } .manual-field { @@ -521,7 +757,7 @@ margin-bottom: 4px; font-size: 11px; font-weight: 600; - color: #888; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } @@ -529,7 +765,7 @@ .manual-field input { width: 100%; padding: 8px 10px; - border: 1px solid #ddd; + border: 1px solid var(--border); border-radius: 6px; font-size: 14px; box-sizing: border-box; @@ -543,23 +779,489 @@ } .reading-value--balanced { - color: #2e7d32; + color: var(--reading-balanced); } .reading-value--unbalanced { - color: #c62828; + color: var(--reading-unbalanced); } .reading-error { margin-top: 4px; font-size: 11px; - color: #c62828; + color: var(--danger); font-style: italic; } .weight-license-block { margin-top: 6px; font-size: 11px; - color: #c62828; + color: var(--danger); font-style: italic; } + + +/* --------------------------------------------------------------------------- + Cockpit theme extras + --------------------------------------------------------------------------- + Everything above is theme-agnostic and driven by tokens. What follows exists + ONLY in the cockpit theme: chrome that has no classic counterpart rather + than a colour that differs between the two. + + Where a rule needs to tell one card from another it names that card's + modifier class (--reading, --tare, --gross, --net). Nothing here selects by + position: the row's cards must be able to change order or count without + cockpit chrome landing on the wrong one. + --------------------------------------------------------------------------- */ + +/* --- Hero -------------------------------------------------------------- */ + +/* Cockpit-specific chrome for the panel: neon border, inner glow, and the + photo bled to the right edge. Structure and layout (icon, pills, the photo + slot) live in the theme-agnostic block above; only the skin is here. */ +[data-theme="cockpit"] .vehicle-hero { + border-color: var(--border-strong); + background: linear-gradient(90deg, var(--surface) 0%, var(--surface) 42%, rgba(7, 13, 24, .7) 65%, var(--surface) 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .05), var(--glow-accent); +} + +[data-theme="cockpit"] .vehicle-icon { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-soft); + box-shadow: var(--glow-accent); +} + +[data-theme="cockpit"] .vehicle-hero-photo { + display: block; + flex: 1; + background-image: var(--hero-image); + /* `contain`, not `100% 100%`: scales proportionally so the truck is never + stretched out of shape. Anchored right so the readout baked into that + edge stays flush against the panel edge; any leftover space falls on + the left, where the fade below already lives. */ + background-size: contain; + background-position: right center; + background-repeat: no-repeat; + /* Only the left edge fades into the panel, where it meets the heading - + the weight readout baked into the photo's right edge is left showing. + `contain` right-anchored leaves empty space before the photo starts, and + a mask-image's percentages are relative to the ELEMENT box, not the + photo - a gradient sized 0-30% of the element would land entirely in + that empty space and never touch a visible pixel. Giving the mask the + same size/position as the background maps the gradient onto the photo's + own rendered box instead, so 30% means 30% into the photo. */ + -webkit-mask-image: linear-gradient(to right, transparent, black 30%); + mask-image: linear-gradient(to right, transparent, black 30%); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-position: right center; + mask-position: right center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + +/* The photo is a nicety, not information - drop it before the bar gets too + cramped to read. */ +@media (max-width: 900px) { + [data-theme="cockpit"] .vehicle-hero-photo { display: none; } +} + +/* --- Cards ------------------------------------------------------------- */ + +/* Clipped top-right corner plus a lit top edge. The clip is what stops a dark + card reading as a plain rectangle; the edge is the light falling on it. */ +[data-theme="cockpit"] .weight-card, +[data-theme="cockpit"] .extra-section { + position: relative; + background: linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%); + clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%); +} + +[data-theme="cockpit"] .weight-card::before, +[data-theme="cockpit"] .extra-section::before { + content: ''; + position: absolute; + left: 0; top: 0; + /* Stops short of the clipped corner so the light does not run off it. */ + width: calc(100% - 14px); + height: 2px; + background: var(--edge-lit); + opacity: .55; +} + +/* The live reading is the instrument: brighter edge, an inner glow, and the + value itself lit. Everything else on the page is deliberately quieter. */ +[data-theme="cockpit"] .weight-card--reading { + border-color: var(--border-strong); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .05), var(--glow-accent); +} +[data-theme="cockpit"] .weight-card--reading::before { opacity: 1; } + +/* A faint arc in the corner, as on the reference's reading tile. It is drawn + deliberately larger than the card and cropped by the card's own clip-path, + which is what leaves an arc rather than a full circle. */ +[data-theme="cockpit"] .weight-card--reading::after { + content: ''; + position: absolute; + right: -40px; bottom: -70px; + width: 170px; height: 170px; + border-radius: 50%; + border: 1px solid var(--accent); + opacity: .12; + pointer-events: none; +} + +[data-theme="cockpit"] .weight-card--net { + border-color: var(--success); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .05), var(--glow-success); +} +[data-theme="cockpit"] .weight-card--net::before { + background: var(--edge-lit-net); + opacity: 1; +} + +/* Lit-tube look: brighter near the top, the base colour underneath, plus a + glow that bleeds past the bar's own edges. The gradient alone does not + read as neon without the bloom - light always spills onto what's near it. */ +[data-theme="cockpit"] .equalizer-bar--balanced { + background: linear-gradient(180deg, #baffe6 0%, var(--success) 45%, var(--success) 100%); + box-shadow: 0 0 6px var(--success), 0 0 14px var(--success); +} +[data-theme="cockpit"] .equalizer-bar--unbalanced { + background: linear-gradient(180deg, #ffd6d6 0%, var(--danger) 45%, var(--danger) 100%); + box-shadow: 0 0 6px var(--danger), 0 0 14px var(--danger); +} + +[data-theme="cockpit"] .equalizer-scale-toggle { + background: var(--accent-soft); + border-color: var(--border-strong); + color: var(--accent); +} +[data-theme="cockpit"] .equalizer-scale-toggle:hover { + border-color: var(--accent); + box-shadow: 0 0 8px rgba(0, 217, 255, .35); +} + +/* Icon tile in its own column, spanning the card's height - a bordered + square holding a glyph, drawn in CSS so no icon library is needed. This is + a separate element (.weight-card-icon) from the label, not a ::before on + the header, so it sits beside the whole label+value+meta stack rather than + inline with just the label text. */ +[data-theme="cockpit"] .weight-card-header { + letter-spacing: .1em; +} + +[data-theme="cockpit"] .weight-card-icon::before { + content: '\2696'; + display: grid; + place-items: center; + width: 34px; height: 34px; + font-size: 15px; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--border-strong); + border-radius: 6px; +} + +[data-theme="cockpit"] .weight-card--net .weight-card-icon::before { + content: '\21C4'; + color: var(--success); + background: var(--success-soft); + border-color: var(--success); +} + +/* --- Buttons ----------------------------------------------------------- */ + +/* Illuminated, not flat-filled: a gradient body, a lit inner top edge and an + outer halo, so the primary action looks switched on. The primary buttons are + the DIRECT children of the action column (Measure, Print Notice); the split + button nested below them is secondary. */ +[data-theme="cockpit"] .weight-actions > .weight-set-btn, +[data-theme="cockpit"] .vehicle-add-btn { + background: linear-gradient(180deg, #ff8a3c 0%, #e8631b 100%); + border: 1px solid #ffa563; + color: #1a0c02; + font-weight: 700; + letter-spacing: .06em; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .35), var(--glow-warning); +} +[data-theme="cockpit"] .weight-actions > .weight-set-btn:hover:not(:disabled), +[data-theme="cockpit"] .vehicle-add-btn:hover { + filter: brightness(1.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), 0 0 34px rgba(240, 138, 60, .55); +} + +[data-theme="cockpit"] .weight-set-btn, +[data-theme="cockpit"] .extra-save-btn { + background: linear-gradient(180deg, var(--accent) 0%, #1f7fae 100%); + border: 1px solid var(--accent-hover); + color: var(--accent-contrast); + font-weight: 700; + letter-spacing: .05em; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28), var(--glow-accent); +} +[data-theme="cockpit"] .weight-set-btn:hover:not(:disabled), +[data-theme="cockpit"] .extra-save-btn:hover { + filter: brightness(1.1); +} +[data-theme="cockpit"] .weight-set-btn:disabled { + background: var(--surface-sunken); + border-color: var(--border); + color: var(--text-muted); + box-shadow: none; +} + +/* --- Sidebar ----------------------------------------------------------- */ + +/* Orange edge strip down the left of the list, as in the reference. */ +[data-theme="cockpit"] .main-left { + position: relative; + background: linear-gradient(180deg, var(--surface) 0%, var(--surface-sunken) 100%); +} +[data-theme="cockpit"] .main-left::before { + content: ''; + position: absolute; + left: 0; top: 14%; bottom: 14%; + width: 2px; + background: linear-gradient(180deg, transparent, var(--warning) 18%, var(--warning) 82%, transparent); + box-shadow: var(--glow-warning); + opacity: .65; +} + +[data-theme="cockpit"] .vehicle-list-item--active { + background: linear-gradient(90deg, var(--accent-soft), transparent); + box-shadow: inset 0 0 22px rgba(53, 169, 221, .10); +} +[data-theme="cockpit"] .vehicle-list-item--active .vehicle-list-number { + color: var(--accent); + text-shadow: 0 0 12px rgba(53, 169, 221, .5); +} + +/* --- Panel furniture --------------------------------------------------- */ + +/* Corner bracket on the data panel: an L drawn from two borders. */ +[data-theme="cockpit"] .extra-section { + padding: 18px; + border: 1px solid var(--border); +} +[data-theme="cockpit"] .extra-section::after { + content: ''; + position: absolute; + left: 10px; bottom: 10px; + width: 16px; height: 16px; + border-left: 1px solid var(--accent); + border-bottom: 1px solid var(--accent); + opacity: .45; + pointer-events: none; +} + +[data-theme="cockpit"] .extra-section h3 { + display: flex; + align-items: center; + gap: 9px; + letter-spacing: .08em; +} +[data-theme="cockpit"] .extra-section h3::before { + content: '\25E8'; + display: grid; + place-items: center; + width: 24px; height: 24px; + font-size: 13px; + color: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--border-strong); + border-radius: 4px; +} + +/* --- Inputs ------------------------------------------------------------ */ + +[data-theme="cockpit"] .extra-field select, +[data-theme="cockpit"] .extra-field input, +[data-theme="cockpit"] .vehicle-search { + background: var(--surface-sunken); + border: 1px solid var(--border); + color: var(--text); +} +[data-theme="cockpit"] .extra-field select:focus, +[data-theme="cockpit"] .extra-field input:focus, +[data-theme="cockpit"] .vehicle-search:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-soft), var(--glow-accent); +} + + +/* --- Workspace rail ---------------------------------------------------- */ + +/* "LOAD WEIGH REPEAT" down the right edge with hazard stripes, on .main so it + is anchored to the window rather than scrolling with the detail panel. + Hidden on narrow windows, where it would overlap the content. */ +[data-theme="cockpit"] .main { position: relative; } + +[data-theme="cockpit"] .main::after { + content: 'LOAD WEIGH REPEAT'; + position: absolute; + right: 10px; + bottom: 30px; + writing-mode: vertical-rl; + font: 700 11px/1 system-ui, sans-serif; + letter-spacing: .42em; + color: var(--text-muted); + opacity: .38; + pointer-events: none; +} + +[data-theme="cockpit"] .main::before { + content: ''; + position: absolute; + right: 8px; + bottom: 0; + width: 16px; + height: 20px; + background: var(--hazard); + opacity: .55; + pointer-events: none; +} + +@media (max-width: 1500px) { + [data-theme="cockpit"] .main::after, + [data-theme="cockpit"] .main::before { display: none; } +} + +/* --- Neon corrections -------------------------------------------------- */ + +/* Pools of coloured light behind everything. A flat fill reads as "dark UI"; + these are what make it read as a lit room, and they cost nothing to paint. */ +[data-theme="cockpit"] .main { + background: + radial-gradient(circle at 78% 8%, rgba(0, 217, 255, .07), transparent 26%), + radial-gradient(circle at 12% 82%, rgba(0, 241, 155, .04), transparent 30%), + var(--bg); +} + +/* Every panel gets a visible lit border and an inner top highlight, so the + surface looks glassy rather than painted on. 1px of #0d4f66 is the + difference between "a box" and "a lit panel". */ +[data-theme="cockpit"] .weight-card, +[data-theme="cockpit"] .extra-section { + border: 1px solid var(--border); + box-shadow: + inset 0 1px 0 rgba(0, 217, 255, .10), + inset 0 0 40px rgba(0, 217, 255, .03), + 0 2px 14px rgba(0, 0, 0, .55); +} + +/* The readouts glow. This is the single biggest contributor to the look, and + it belongs on the numbers because they are what the screen is for. */ +[data-theme="cockpit"] .weight-card-value { + text-shadow: 0 0 12px currentColor; + letter-spacing: .02em; +} +[data-theme="cockpit"] .weight-card-value--reading { + letter-spacing: .03em; +} +[data-theme="cockpit"] .weight-card--net .weight-card-value { + color: var(--success); +} + +/* Re-stated after the shared panel rule above, which would otherwise flatten + the reading tile back to the ordinary card border and shadow. */ +[data-theme="cockpit"] .weight-card--reading { + border-color: var(--border-strong); + box-shadow: + inset 0 1px 0 rgba(0, 217, 255, .10), + inset 0 0 40px rgba(0, 217, 255, .03), + var(--glow-accent); +} + +/* Labels: small, wide-tracked, cyan — the reference's instrument-panel voice. */ +[data-theme="cockpit"] .weight-card-header { + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + opacity: .92; +} +[data-theme="cockpit"] .weight-card--net .weight-card-header { + color: var(--success); +} + +[data-theme="cockpit"] .weight-card-icon::before { + box-shadow: 0 0 12px rgba(0, 217, 255, .35); +} + +/* FIX: Design Notice was rendering orange. It carries .weight-set-btn inside + the action column, so the primary-action rule caught it. Only a DIRECT child + of that column is primary; the split button nested below is secondary. */ +[data-theme="cockpit"] .weight-actions > .weight-set-btn, +[data-theme="cockpit"] .vehicle-add-btn { + background: linear-gradient(180deg, #ff8a3c 0%, #e8541b 100%); + border: 1px solid #ffa563; + color: #1a0c02; + text-shadow: none; +} + +/* ...and explicitly hand the split button back its secondary treatment. */ +[data-theme="cockpit"] .design-notice-main, +[data-theme="cockpit"] .design-notice-caret { + background: linear-gradient(180deg, rgba(0, 217, 255, .16) 0%, rgba(0, 217, 255, .05) 100%); + border: 1px solid var(--border-strong); + color: var(--accent); + box-shadow: inset 0 1px 0 rgba(0, 217, 255, .18); + text-shadow: 0 0 10px rgba(0, 217, 255, .45); +} +[data-theme="cockpit"] .design-notice-main:hover:not(:disabled), +[data-theme="cockpit"] .design-notice-caret:hover:not(:disabled) { + background: linear-gradient(180deg, rgba(0, 217, 255, .28) 0%, rgba(0, 217, 255, .10) 100%); + border-color: var(--accent); +} + +/* The combinations table now uses tokens directly (its stylesheet was + converted), so it needs only its header tinted to the panel voice. */ +[data-theme="cockpit"] .extra-combos-table th { + background: rgba(0, 217, 255, .07); + color: var(--accent); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; +} + +/* Section headings in the panel voice. */ +[data-theme="cockpit"] .extra-section h3, +[data-theme="cockpit"] .extra-section h4 { + color: var(--accent); + text-shadow: 0 0 12px rgba(0, 217, 255, .35); + font-size: 13px; + letter-spacing: .1em; + text-transform: uppercase; +} + +/* Field labels, quieter than the headings but still in voice. */ +[data-theme="cockpit"] .extra-field label { + color: var(--text-muted); + font-size: 10px; + letter-spacing: .1em; + text-transform: uppercase; +} + +/* Sidebar rows: a lit left edge on the active one, and readable weights. */ +[data-theme="cockpit"] .vehicle-list-item { + border-bottom: 1px solid rgba(13, 79, 102, .5); +} +[data-theme="cockpit"] .vehicle-list-item--active { + border-left-color: var(--accent); + box-shadow: inset 0 0 26px rgba(0, 217, 255, .10); +} +[data-theme="cockpit"] .vehicle-list-weight { + color: var(--text-muted); +} +[data-theme="cockpit"] .vehicle-list-item--active .vehicle-list-weight { + color: var(--accent); +} + +/* The header needs its own lit underline, or the bar floats. */ +[data-theme="cockpit"] .app-header { + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 0 rgba(0, 217, 255, .25), 0 6px 24px rgba(0, 0, 0, .6); +} diff --git a/frontend/src/components/Main.jsx b/frontend/src/components/Main.jsx index 1a4e9b0..036c428 100644 --- a/frontend/src/components/Main.jsx +++ b/frontend/src/components/Main.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import Header from './Header'; import useSerialData from '../hooks/useSerialData'; @@ -12,6 +12,7 @@ import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown'; import ReportPreviewModal from './ReportPreviewModal'; import ExtraCombinationsTable from './ExtraCombinationsTable'; import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility'; +import { buildVehicleReportData } from '../services/vehicleReportData'; import api from '../services/api'; import './Main.css'; import './License/LicenseManager.css'; @@ -27,7 +28,7 @@ export default function Main() { const [selectedVehicleId, setSelectedVehicleId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [showNewForm, setShowNewForm] = useState(false); - const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); + const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' }); const [newExtraData, setNewExtraData] = useState({}); const [error, setError] = useState(''); const [isSaving, setIsSaving] = useState(false); @@ -99,6 +100,46 @@ export default function Main() { const isBalanced = parsed.balanced; const readingError = parsed.error; + // Equalizer bar history: last 40 readings, oldest first, newest at the + // right. A ticking interval, not a useEffect keyed on value changes - a + // static weight (scale settled, or nothing on it) would otherwise never + // push a new bar at all, since the effect only reruns when the value + // itself changes. Sampling on a timer keeps the strip moving continuously + // whether or not the reading is currently changing. + const EQUALIZER_BAR_COUNT = 40; + const EQUALIZER_SAMPLE_INTERVAL_MS = 250; + const EQUALIZER_FIXED_MAX_KG = 60000; + const [equalizerBars, setEqualizerBars] = useState([]); + // 'fixed' makes bar height comparable across vehicles/sessions; 'auto' + // scales to whatever is on screen right now, so a light load still fills + // the card. Not persisted - always starts on 'fixed' per session. + const [equalizerScaleMode, setEqualizerScaleMode] = useState('fixed'); + const latestReadingRef = useRef({ weight: null, balanced: null }); + latestReadingRef.current = { weight: currentWeight, balanced: isBalanced }; + useEffect(() => { + const id = setInterval(() => { + const { weight, balanced } = latestReadingRef.current; + if (weight === null || isNaN(weight)) return; + setEqualizerBars(prev => [...prev, { value: weight, balanced }].slice(-EQUALIZER_BAR_COUNT)); + }, EQUALIZER_SAMPLE_INTERVAL_MS); + return () => clearInterval(id); + }, []); + + // Padded to a fixed 40 slots (empty ones render as zero-height bars). + // 'fixed' scales against EQUALIZER_FIXED_MAX_KG (clamped at 100% above + // it); 'auto' scales against the tallest bar currently visible, so the + // row re-scales as the window slides instead of clipping. + const equalizerSlots = useMemo(() => { + const maxValue = equalizerScaleMode === 'fixed' + ? EQUALIZER_FIXED_MAX_KG + : Math.max(1, ...equalizerBars.map(b => Math.abs(b.value))); + const padding = Array(Math.max(0, EQUALIZER_BAR_COUNT - equalizerBars.length)).fill(null); + return [...padding, ...equalizerBars].map(bar => ({ + balanced: bar?.balanced ?? null, + heightPct: bar ? Math.min(100, Math.max(6, (Math.abs(bar.value) / maxValue) * 100)) : 0, + })); + }, [equalizerBars, equalizerScaleMode]); + // Tell the bridge how to talk to this scale. Polled indicators stay silent // until asked, so the bridge must send the command on its own timer - a // browser tab cannot be the heartbeat (background tabs get throttled, and a @@ -152,7 +193,7 @@ export default function Main() { // Cancel new vehicle form const handleCancelNew = () => { setShowNewForm(false); - setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); + setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' }); setNewExtraData({}); setError(''); }; @@ -168,6 +209,7 @@ export default function Main() { trailer1_number: newForm.trailer1_number.trim() || null, trailer2_number: newForm.trailer2_number.trim() || null, driver_pid: newForm.driver_pid.trim() || null, + driver_name: newForm.driver_name.trim(), }; // Include extra data if any values set const visibleNewExtra = prunedExtra(newExtraData, visibleForNew); @@ -178,7 +220,7 @@ export default function Main() { const res = await api.post('/api/vehicles/', payload); setSelectedVehicleId(res.data.id); setShowNewForm(false); - setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); + setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' }); setNewExtraData({}); } catch (err) { const data = err.response?.data; @@ -285,53 +327,11 @@ export default function Main() { const canMeasure = !!currentUser?.can_measure; const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null; - // The single recorded weighing, while its tare/gross role is still undecided. - // The backend parks a lone reading in tare, but a lighter second reading can move it to gross. - const firstWeighing = useMemo(() => { - if (!selectedVehicle || isFinalized) return null; - if (selectedVehicle.tare != null) { - return { - value: selectedVehicle.tare, - date: selectedVehicle.tare_date, - userName: selectedVehicle.tare_user_name, - manual: selectedVehicle.tare_manual, - }; - } - if (selectedVehicle.gross != null) { - return { - value: selectedVehicle.gross, - date: selectedVehicle.gross_date, - userName: selectedVehicle.gross_user_name, - manual: selectedVehicle.gross_manual, - }; - } - return null; - }, [selectedVehicle, isFinalized]); - - // Vehicle data shaped for report preview/design - // Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value } - const vehicleReportData = useMemo(() => { - if (!selectedVehicle) return null; - const rawExtra = selectedVehicle.extra?.data || {}; - const extra = {}; - for (const [code, value] of Object.entries(rawExtra)) { - const def = definitions[code]; - if (!def) continue; - if (def.kind === 'lookup') { - const entry = (entries[code] || []).find(e => e.id === value); - extra[def.name] = entry?.display_value ?? ''; - } else { - extra[def.name] = value ?? ''; - } - } - return { - vehicle: { - ...selectedVehicle, - net: netWeight, - extra, - } - }; - }, [selectedVehicle, netWeight, definitions, entries]); + // Vehicle data shaped for report preview/design (shared with History) + const vehicleReportData = useMemo( + () => buildVehicleReportData(selectedVehicle, definitions, entries), + [selectedVehicle, definitions, entries] + ); // Look up the saved notice report by name. Server-side filter — ?name= is a // real filter now that django-filter is configured, replacing the old @@ -351,9 +351,12 @@ export default function Main() { import('./ReportEditor/a4/print/printReport').catch(() => {}); }; - // Print Notice is kind-aware: an A4 report goes straight to the browser - // print dialog (where "Save as PDF" is a destination), a matrix report opens - // the existing on-screen modal exactly as before. + // Print Notice always opens the unified preview modal now — the modal + // itself fetches both the matrix and a4 notice_report variants and decides + // what Print/Download do based on whichever kind is selected in its own + // switcher. Previously an a4 report skipped the modal and went straight to + // window.print(); that bypass is gone so both kinds get the same + // preview/print/download/mark-notice-printed flow. const handlePrintNotice = async () => { if (isPrinting) return; setIsPrinting(true); @@ -363,14 +366,7 @@ export default function Main() { setError('Report "notice_report" not found. Create it in the Report Editor first.'); return; } - if (report.kind === 'a4') { - // Lazy so Main.jsx does not pull the A4 print tree into the - // main bundle for operators who never print an A4 notice. - const { printA4Report } = await import('./ReportEditor/a4/print/printReport'); - await printA4Report(report, vehicleReportData); - } else { - setShowNoticeReport(true); - } + setShowNoticeReport(true); } catch (err) { console.error('Failed to print notice:', err); setError('Failed to load the notice report'); @@ -487,7 +483,7 @@ export default function Main() {

New Vehicle

-
+
+
+ + setNewForm(prev => ({ ...prev, driver_name: e.target.value }))} + disabled={isSaving} + /> +
{vehicleNomenclatures.length > 0 && (
-

Additional Data

+

Additional Data

{visibleForNew.map(nom => (
@@ -561,7 +566,7 @@ export default function Main() { @@ -575,179 +580,249 @@ export default function Main() {
{error &&
{error}
} -

{selectedVehicle.vehicle_number}

- - {/* Current reading */} -
- -
- {currentWeight !== null && !isNaN(currentWeight) - ? `${currentWeight} kg` - : (isConnected ? 'Waiting...' : 'Disconnected') - } + {/* Header bar: icon, plate number, derived status pills, and (cockpit + only) a photo. Both pills come from real state, not decoration: + "on scale" is this vehicle having a live reading right now, and + the weighing status is the same isFinalized used by the cards + below. */} +
+
+ +

{selectedVehicle.vehicle_number}

+ + ● {isFinalized ? 'Finalized' : 'In progress'} + + {isConnected && currentWeight !== null && !isNaN(currentWeight) && ( + ◉ On Scale + )}
- {isConnected && readingError && latestReading && ( -
- {readingError} + + {/* Equalizer: last 40 live readings, oldest to newest left to + right. Green when the last reading was stable, rose when it + wasn't. The corner button switches between scaling against a + fixed 60t ceiling (comparable across vehicles) and scaling to + whatever is on screen right now (fills the strip for any + load). No border/background of its own - it sits directly on + the hero bar between the pills and the photo. */} +
+ +
+ {equalizerSlots.map((slot, i) => ( + + ))}
- )} +
+ + - {/* Weighing section */} + {/* Weighing section - four readouts plus an action column, all of + which always render, so the row never changes its column count + as a weighing progresses. The readouts hold no controls: every + button lives in the action column. */}
- {/* Before both weights exist, the first reading's role is not - final yet - show it neutrally so it doesn't appear to jump. */} - {selectedVehicle.tare == null || selectedVehicle.gross == null ? ( -
-
First weighing
-
- {firstWeighing ? `${firstWeighing.value} kg` : '---'} + {/* Current reading, which doubles as the Measure card: + both showed the same live weight. Icon and text sit in + separate columns (weight-card-icon / -content), matching + the reference layout - the icon spans the card's full + height instead of sitting inline with the label. */} +
+