diff --git a/frontend/src/components/Main.css b/frontend/src/components/Main.css
index 8b9864f..ab93fdc 100644
--- a/frontend/src/components/Main.css
+++ b/frontend/src/components/Main.css
@@ -1,7 +1,17 @@
+/* Fills the rail layout's content column (see NavRail.css); the licence
+ banner, when shown, takes its height and .main gets the rest. */
+.main-page {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
.main {
display: flex;
flex-direction: row;
- height: calc(100vh - 74px);
+ flex: 1;
+ min-height: 0;
}
/* Left panel - vehicle list */
@@ -55,6 +65,7 @@
.vehicle-list {
flex: 1;
overflow-y: auto;
+ padding-top: 7px; /* room for a marker above the first row */
}
.vehicle-list-item {
@@ -94,6 +105,233 @@
font-size: var(--fs-base);
}
+/* Staged list changes (hooks/useStagedList, components/VehicleListStaging).
+ The row durations come from JS as --vl-enter-ms and --vl-exit-ms (set on
+ .vehicle-list), so they cannot drift from the hook's timers. The fallbacks
+ only matter if the list is rendered without them.
+
+ Insert: a marker line with a shimmer running along it; when the hook moves
+ it to `distorting` (the --distorting class) it wobbles and bulges into a
+ lens (SMIL path morph, timed in the JSX). The row opens out of that lens
+ and reveals its plate. A leaving row stays put, marked, before it
+ collapses. While the list is held both just keep waiting.
+
+ The marker takes no layout space: it is 0 high, and its line, shimmer and
+ lens are drawn over the border between the two rows, so the rows only move
+ once the new row actually opens. */
+.vehicle-list-marker {
+ position: relative;
+ z-index: 2; /* over the neighbouring rows */
+ height: 0;
+ margin: 0 var(--space-2);
+ overflow-x: clip; /* the shimmer runs past both ends */
+ pointer-events: none;
+ animation: vehicle-list-marker-in 200ms ease-out;
+}
+
+.vehicle-list-marker-line {
+ position: absolute;
+ left: 0;
+ top: -6px;
+ display: block;
+ width: 100%;
+ height: 12px; /* = H in VehicleListStaging.jsx */
+ overflow: visible;
+ opacity: .55;
+}
+.vehicle-list-marker--distorting .vehicle-list-marker-line {
+ animation: vehicle-list-marker-wake 250ms ease-out forwards;
+}
+.vehicle-list-marker-line path {
+ fill: var(--warning-soft); /* only shows once the edges part (the lens) */
+ stroke: var(--warning);
+ stroke-width: 2.5px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ vector-effect: non-scaling-stroke;
+}
+
+/* A soft bright spot travelling along the calm line; it is absorbed (scaled
+ away) when the line starts to distort. `scale` is used for that so it does
+ not fight the travel animation's transform/opacity. */
+.vehicle-list-marker::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 28%;
+ height: 6px;
+ margin-top: -3px;
+ background: radial-gradient(closest-side, var(--warning), transparent);
+ pointer-events: none;
+ animation: vehicle-list-shimmer 1.5s cubic-bezier(.45, 0, .55, 1) infinite;
+}
+/* Same first name, so the travel carries on rather than restarting. */
+.vehicle-list-marker--distorting::after {
+ animation:
+ vehicle-list-shimmer 1.5s cubic-bezier(.45, 0, .55, 1) infinite,
+ vehicle-list-shimmer-out 200ms ease-in forwards;
+}
+
+.vehicle-list-item--leaving {
+ cursor: default;
+ box-shadow: inset 3px 0 0 var(--warning);
+ animation: vehicle-list-leaving-pulse 1.2s ease-in-out infinite;
+}
+.vehicle-list-item--leaving,
+.vehicle-list-item--leaving:hover {
+ background: var(--warning-soft);
+}
+
+/* Opening: the row grows from 0 (the marker took no space) with a small
+ overshoot, while ::before (the lens) flattens into two orange edges and
+ fades. It starts as the marker's 12px lens centred on the row's top edge,
+ which the clip margin lets show while the row is still flat (without
+ overflow-clip-margin support it is clipped until the row grows). Not
+ clickable until stable; `isolation` keeps the lens behind the text. */
+.vehicle-list-item--entering {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ overflow: clip;
+ overflow-clip-margin: 6px;
+ cursor: default;
+ pointer-events: none;
+ animation: vehicle-list-open var(--vl-enter-ms, 800ms) both;
+}
+.vehicle-list-item--entering::before {
+ content: '';
+ position: absolute;
+ z-index: -1;
+ inset: 0 var(--space-2);
+ border-block: 2.5px solid var(--warning);
+ border-radius: 50%;
+ background: var(--warning-soft);
+ pointer-events: none;
+ animation: vehicle-list-lens var(--vl-enter-ms, 800ms) both;
+}
+
+/* Plate characters (only while entering) rise and sharpen with a stagger set
+ inline; the weight follows. */
+.vehicle-list-char {
+ display: inline-block;
+ white-space: pre;
+ animation: vehicle-list-char-in calc(var(--vl-enter-ms, 800ms) * .3) cubic-bezier(.2, .8, .2, 1) both;
+}
+.vehicle-list-item--entering .vehicle-list-weight {
+ animation: vehicle-list-weight-in calc(var(--vl-enter-ms, 800ms) * .3) ease-out
+ calc(var(--vl-enter-ms, 800ms) * .62) both;
+}
+
+.vehicle-list-item--exiting {
+ overflow: hidden;
+ pointer-events: none;
+ animation: vehicle-list-exit var(--vl-exit-ms, 280ms) ease-in forwards;
+}
+
+@keyframes vehicle-list-marker-in {
+ from { transform: scaleX(0); opacity: 0; }
+}
+
+@keyframes vehicle-list-marker-wake {
+ to { opacity: 1; }
+}
+
+@keyframes vehicle-list-shimmer {
+ 0% { transform: translateX(-100%); opacity: 0; }
+ 15%, 85% { opacity: 1; }
+ 100% { transform: translateX(358%); opacity: 0; } /* 100% / 28% of the line */
+}
+
+@keyframes vehicle-list-shimmer-out {
+ to { scale: 0; }
+}
+
+@keyframes vehicle-list-leaving-pulse {
+ 0%, 100% { opacity: .7; }
+ 50% { opacity: .45; }
+}
+
+/* --vl-row-h is the row's natural height, measured in JS before the first
+ paint. Padding grows over the whole run; the flex centring does not depend
+ on it, so the text stays put. */
+@keyframes vehicle-list-open {
+ 0% {
+ height: 0; /* the marker takes no space */
+ padding-top: 0;
+ padding-bottom: 0;
+ border-bottom-width: 0;
+ animation-timing-function: cubic-bezier(.2, .75, .3, 1);
+ }
+ 42% { height: calc(var(--vl-row-h, 40px) + 5px); animation-timing-function: ease-in-out; }
+ 58% { height: calc(var(--vl-row-h, 40px) - 2px); animation-timing-function: ease-in-out; }
+ 72%, 100% { height: var(--vl-row-h, 40px); }
+}
+
+/* Starts 12px high (the marker's lens) around the 0-high row, then fits it. */
+@keyframes vehicle-list-lens {
+ 0% {
+ top: -6px;
+ bottom: -6px;
+ border-radius: 50%;
+ animation-timing-function: cubic-bezier(.2, .75, .3, 1);
+ }
+ 45% { top: 0; bottom: 0; border-radius: var(--radius-sm); opacity: 1; }
+ 90%, 100% { border-radius: var(--radius-sm); opacity: 0; }
+}
+
+@keyframes vehicle-list-char-in {
+ from { opacity: 0; transform: translateY(.45em); filter: blur(3px); }
+ to { opacity: 1; transform: none; filter: blur(0); }
+}
+
+@keyframes vehicle-list-weight-in {
+ from { opacity: 0; transform: translateX(var(--space-1-5)); }
+}
+
+/* Height animates via max-height; the cap only needs to exceed a row. */
+@keyframes vehicle-list-exit {
+ from { max-height: 64px; opacity: .45; }
+ to {
+ max-height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ border-bottom-width: 0;
+ opacity: 0;
+ transform: translateX(calc(-1 * var(--space-2)));
+ }
+}
+
+/* A static, full-strength line while waiting (the JSX also drops the SMIL
+ distortion) and no opening: the hook skips the entering/exiting phases, so
+ a row replacing a marker just fades in quickly. */
+@media (prefers-reduced-motion: reduce) {
+ .vehicle-list-marker,
+ .vehicle-list-marker-line,
+ .vehicle-list-marker--distorting .vehicle-list-marker-line {
+ animation: none;
+ }
+ .vehicle-list-marker-line {
+ opacity: 1;
+ }
+ .vehicle-list-marker::after,
+ .vehicle-list-item--entering::before {
+ display: none;
+ }
+ .vehicle-list-item--entering .vehicle-list-weight,
+ .vehicle-list-char,
+ .vehicle-list-item--exiting {
+ animation: none;
+ }
+ .vehicle-list-item:not(.vehicle-list-item--leaving) {
+ animation: vehicle-list-fade-in 150ms ease-out;
+ }
+}
+
+@keyframes vehicle-list-fade-in {
+ from { opacity: 0; }
+}
+
/* Right panel */
.main-right {
flex: 1;
@@ -288,8 +526,7 @@
/* 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 {
+.weight-actions > .weight-manual-btn {
margin-top: 0;
}
@@ -430,87 +667,6 @@
cursor: not-allowed;
}
-/* Design Notice split button โ main action plus a caret that opens an explicit
- editor choice. The two halves read as one control, so they share the gradient
- and only a hairline divides them. */
-.design-notice-split {
- position: relative;
- display: flex;
- gap: 1px;
- margin-top: var(--space-1-5);
-}
-
-/* .weight-set-btn is width:100%; inside the split the halves are sized by flex. */
-.design-notice-split .weight-set-btn {
- width: auto;
- margin-top: 0;
-}
-
-.design-notice-main {
- flex: 1;
- border-radius: 6px 0 0 6px;
-}
-
-.design-notice-caret {
- flex: 0 0 28px;
- padding: var(--space-2) 0;
- border-radius: 0 6px 6px 0;
-}
-
-/* Sits under the menu and over everything else, so a click anywhere outside
- closes it without needing a document-level listener. */
-.design-notice-backdrop {
- position: fixed;
- inset: 0;
- z-index: 20;
-}
-
-.design-notice-menu {
- position: absolute;
- top: calc(100% + 4px);
- right: 0;
- left: 0;
- z-index: 21;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: 6px;
- box-shadow: 0 6px 20px var(--shadow-overlay);
- overflow: hidden;
-}
-
-.design-notice-menu button {
- display: block;
- width: 100%;
- padding: var(--space-2) var(--space-3);
- background: none;
- border: none;
- text-align: left;
- cursor: pointer;
- font-size: var(--fs-sm);
- color: var(--text);
-}
-
-.design-notice-menu button + button {
- border-top: 1px solid var(--border);
-}
-
-.design-notice-menu button:hover {
- background: var(--surface-hover);
-}
-
-.design-notice-menu strong {
- display: block;
- font-size: var(--fs-base);
- font-weight: var(--fw-semibold);
-}
-
-.design-notice-menu span {
- display: block;
- margin-top: 1px;
- font-size: var(--fs-xs);
- color: var(--text-muted);
-}
-
/* Extra fields section */
.extra-section {
border-top: 1px solid var(--border);
@@ -638,9 +794,15 @@
/* Responsive */
@media (max-width: 768px) {
+ /* Stacked panels scroll as one page (the rail layout's content column)
+ rather than each in a squeezed box. */
+ .main-page {
+ flex: 1 0 auto;
+ }
+
.main {
flex-direction: column;
- height: auto;
+ flex: none;
}
.main-left {
@@ -975,8 +1137,7 @@
/* 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. */
+ the DIRECT children of the action column (Print Notice, One weight only). */
[data-theme="cockpit"] .weight-actions > .weight-set-btn,
[data-theme="cockpit"] .vehicle-add-btn {
background: linear-gradient(180deg, #ff8a3c 0%, #e8631b 100%);
@@ -1033,6 +1194,16 @@
background: linear-gradient(90deg, var(--accent-soft), transparent);
box-shadow: inset 0 0 22px rgba(53, 169, 221, .10);
}
+/* The glow follows the line's shape, wobble and lens included. */
+[data-theme="cockpit"] .vehicle-list-marker-line {
+ filter: drop-shadow(0 0 3px var(--warning));
+}
+[data-theme="cockpit"] .vehicle-list-item--entering::before {
+ box-shadow: inset 0 0 14px var(--warning-soft), 0 0 10px var(--warning-soft);
+}
+[data-theme="cockpit"] .vehicle-list-item--leaving {
+ background: linear-gradient(90deg, var(--warning-soft), transparent);
+}
[data-theme="cockpit"] .vehicle-list-item--active .vehicle-list-number {
color: var(--accent);
text-shadow: 0 0 12px rgba(53, 169, 221, .5);
@@ -1191,9 +1362,8 @@
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. */
+/* Only a DIRECT child of the action column is primary; restated late so it
+ wins over the generic cockpit .weight-set-btn treatment. */
[data-theme="cockpit"] .weight-actions > .weight-set-btn,
[data-theme="cockpit"] .vehicle-add-btn {
background: linear-gradient(180deg, #ff8a3c 0%, #e8541b 100%);
@@ -1202,21 +1372,6 @@
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 {
@@ -1259,9 +1414,3 @@
[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 036c428..73852f9 100644
--- a/frontend/src/components/Main.jsx
+++ b/frontend/src/components/Main.jsx
@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react';
-import { useNavigate } from 'react-router-dom';
-import Header from './Header';
import useSerialData from '../hooks/useSerialData';
+import useStagedList, { prefersReducedMotion } from '../hooks/useStagedList';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
@@ -11,19 +10,21 @@ import { parseReading } from '../services/scaleProtocols';
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
import ReportPreviewModal from './ReportPreviewModal';
import ExtraCombinationsTable from './ExtraCombinationsTable';
+import { LIST_STAGING, listStagingStyle, PendingMarker, RevealPlate, measureOpeningRow, shouldHoldList } from './VehicleListStaging';
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
-import { buildVehicleReportData } from '../services/vehicleReportData';
+import { buildVehicleReportData, vehicleNetWeight } from '../services/vehicleReportData';
import api from '../services/api';
import './Main.css';
import './License/LicenseManager.css';
+const getVehicleKey = v => v.id;
+
export default function Main() {
- const navigate = useNavigate();
const { readings, isConnected } = useSerialData();
const { vehicles } = useNomenclatures();
const { currentUser } = useAuth();
const { selectedProtocol } = useScaleProtocols();
- const { allowed: licenseAllowed, detail: licenseDetail, stationKey, status: licenseStatus } = useLicense();
+ const { allowed: licenseAllowed, detail: licenseDetail, stationKey } = useLicense();
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
@@ -34,7 +35,7 @@ export default function Main() {
const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
- const [showDesignMenu, setShowDesignMenu] = useState(false);
+ const [isOneWeighing, setIsOneWeighing] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
@@ -165,12 +166,60 @@ export default function Main() {
[vehicles, selectedVehicleId]
);
- // Filtered vehicles
+ // While the mouse is over the list, staged changes wait (useStagedList
+ // `hold`). Only mouse/pen hold, so a touch screen never gets stuck. A
+ // click on a row releases; a mouse move REHOLD_PX away from the click
+ // point holds again (shouldHoldList).
+ const [isListHeld, setIsListHeld] = useState(false);
+ const lastPointerRef = useRef(null);
+ const holdReleasedAtRef = useRef(null);
+ const trackListPointer = e => {
+ if (e.pointerType === 'mouse' || e.pointerType === 'pen') {
+ lastPointerRef.current = { x: e.clientX, y: e.clientY };
+ }
+ };
+ const handleListPointerOver = e => {
+ trackListPointer(e);
+ if (!shouldHoldList(e, holdReleasedAtRef.current)) return;
+ holdReleasedAtRef.current = null;
+ if (!isListHeld) setIsListHeld(true);
+ };
+ const handleListPointerLeave = () => {
+ holdReleasedAtRef.current = null;
+ setIsListHeld(false);
+ };
+ const releaseListHoldOnClick = () => {
+ holdReleasedAtRef.current = lastPointerRef.current;
+ setIsListHeld(false);
+ };
+ useEffect(() => {
+ const release = () => setIsListHeld(false);
+ const handleVisibilityChange = () => { if (document.hidden) release(); };
+ window.addEventListener('blur', release);
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ return () => {
+ window.removeEventListener('blur', release);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, []);
+
+ // The list lags the live data so rows never shift under the cursor
+ // unannounced; the search filter applies to it instantly.
+ const stagedVehicles = useStagedList(vehicles, {
+ getKey: getVehicleKey,
+ delay: LIST_STAGING.delay,
+ distortMs: LIST_STAGING.distortMs,
+ enterMs: LIST_STAGING.enterMs,
+ animationMs: LIST_STAGING.exitMs,
+ hold: isListHeld,
+ releaseStagger: LIST_STAGING.releaseStagger,
+ });
const filteredVehicles = useMemo(() => {
- if (!searchQuery.trim()) return vehicles;
+ if (!searchQuery.trim()) return stagedVehicles;
const q = searchQuery.toLowerCase();
- return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q));
- }, [vehicles, searchQuery]);
+ return stagedVehicles.filter(({ item }) => item.vehicle_number.toLowerCase().includes(q));
+ }, [stagedVehicles, searchQuery]);
+ const reduceMotion = prefersReducedMotion();
// When vehicle selection changes, load its extra data
useEffect(() => {
@@ -185,7 +234,7 @@ export default function Main() {
const handleNewVehicle = () => {
setSelectedVehicleId(null);
setShowNewForm(true);
- setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
+ setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({});
setError('');
};
@@ -245,6 +294,24 @@ export default function Main() {
}
};
+ // Finalize on the single recorded weight (external clients weighed once).
+ // Like handleMeasure, the screen updates from the server's SSE broadcast.
+ const handleOneWeight = async () => {
+ if (!selectedVehicle || isOneWeighing) return;
+ if (!window.confirm('Finalize with a single weight?')) return;
+ setIsOneWeighing(true);
+ setError('');
+ try {
+ await api.post(`/api/vehicles/${selectedVehicle.id}/one-weight/`, {
+ station_key: stationKey,
+ });
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Failed to finalize with a single weight');
+ } finally {
+ setIsOneWeighing(false);
+ }
+ };
+
// Record a weighing entered by hand - requires a note
const handleManualMeasure = async () => {
if (!selectedVehicle) return;
@@ -319,13 +386,18 @@ export default function Main() {
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
- // Net weight
- const netWeight = (selectedVehicle?.tare != null && selectedVehicle?.gross != null)
- ? selectedVehicle.gross - selectedVehicle.tare
- : null;
+ // Net weight - the server's net when set (a one-weight vehicle has no tare,
+ // so gross - tare cannot produce it), otherwise derived from both weighings.
+ const netWeight = vehicleNetWeight(selectedVehicle);
const canMeasure = !!currentUser?.can_measure;
- const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
+ // doc_number is assigned on finalization by both the second weighing and
+ // the one-weight path, so it is the one signal that covers both.
+ const isFinalized = selectedVehicle?.doc_number != null;
+
+ // Weighed exactly once and still open - may be closed on that single weight.
+ const canOneWeight = canMeasure && !isFinalized && !selectedVehicle?.is_void
+ && (selectedVehicle?.tare != null) !== (selectedVehicle?.gross != null);
// Vehicle data shaped for report preview/design (shared with History)
const vehicleReportData = useMemo(
@@ -375,67 +447,8 @@ export default function Main() {
}
};
- // Design Notice routes to the editor that can actually edit the saved
- // report. A matrix report opened in the mm editor would read its character
- // coordinates as millimetres, so the kind decides the route, not a default.
- const handleDesignNotice = async () => {
- const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
- try {
- const report = await fetchNoticeReport();
- navigate(report?.kind === 'a4' ? '/report-editor-a4' : '/report-editor', { state });
- } catch (err) {
- // Nothing saved yet, or the lookup failed โ the matrix editor is the
- // existing default and pre-fills the name so Save creates it.
- console.error('Failed to resolve notice report kind:', err);
- navigate('/report-editor', { state });
- }
- };
-
- /**
- * Open a chosen editor regardless of what is saved.
- *
- * The automatic route above is right for editing an existing notice; this is
- * for deliberately starting the OTHER kind. Opening a report in the wrong
- * editor reinterprets its coordinates โ character cells as millimetres, or
- * the reverse โ so a mismatch is confirmed rather than done silently.
- */
- const handleDesignAs = async (kind) => {
- setShowDesignMenu(false);
- const state = { reportName: 'notice_report', vehicleData: vehicleReportData };
- const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor';
-
- let saved = null;
- try {
- saved = await fetchNoticeReport();
- } catch (err) {
- console.error('Failed to resolve notice report kind:', err);
- }
-
- if (saved && saved.kind !== kind) {
- const savedLabel = saved.kind === 'a4' ? 'A4' : 'matrix';
- const targetLabel = kind === 'a4' ? 'A4' : 'matrix';
- const ok = window.confirm(
- `"notice_report" is saved as a ${savedLabel} report.\n\n` +
- `Opening it in the ${targetLabel} editor will read its positions in the ` +
- `wrong units, and saving would overwrite the ${savedLabel} layout.\n\n` +
- `Open the ${targetLabel} editor anyway?`
- );
- if (!ok) return;
- }
-
- navigate(route, { state });
- };
-
return (
-
-
- {licenseStatus && !licenseStatus.valid && (
-
- {licenseStatus.in_grace
- ? <>This installation is not licensed. Everything keeps working for {licenseStatus.grace_days_left} more days.>
- : <>This installation is not licensed. Only {licenseStatus.stations} station can measure.>}
-
- )}
+
{/* Left panel - Vehicle list */}
@@ -455,20 +468,46 @@ export default function Main() {
/>
@@ -748,69 +787,31 @@ export default function Main() {
- {/* Action column. Print/Design only now - Measure/Manual entry
- moved into the Current Reading card, since they act on that
- card's live value. This column is empty (just its panel
- chrome) until weighing is finalized. */}
+ {/* Action column. Measure/Manual entry live in the Current
+ Reading card, since they act on that card's live value. Here:
+ One weight only after a single weighing, Print Notice once
+ finalized; otherwise empty (just its panel chrome). */}
- {netWeight !== null && (
- <>
-
- {/* Split button: the main action routes by
- the saved report's kind; the caret opens
- an explicit choice for starting the other
- kind. */}
-