added print, download as pdf. no puppeteer, we'll see
This commit is contained in:
+277
-202
@@ -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() {
|
||||
<h2 className="vehicle-detail-title">New Vehicle</h2>
|
||||
|
||||
<div className="vehicle-form-section">
|
||||
<div className="extra-fields">
|
||||
<div className="vehicle-new-fields">
|
||||
<div className="extra-field">
|
||||
<label>Vehicle Number *</label>
|
||||
<input
|
||||
@@ -525,16 +521,25 @@ export default function Main() {
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="extra-field">
|
||||
<label>Driver Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newForm.driver_name}
|
||||
onChange={e => setNewForm(prev => ({ ...prev, driver_name: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<h3>Additional Data</h3>
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombinationNew}
|
||||
/>
|
||||
<h3>Additional Data</h3>
|
||||
<div className="extra-fields">
|
||||
{visibleForNew.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
@@ -561,7 +566,7 @@ export default function Main() {
|
||||
<button
|
||||
className="extra-save-btn"
|
||||
onClick={handleCreateVehicle}
|
||||
disabled={isSaving || !newForm.vehicle_number.trim()}
|
||||
disabled={isSaving || !newForm.vehicle_number.trim() || !newForm.driver_name.trim()}
|
||||
>
|
||||
{isSaving ? 'Creating...' : 'Create Vehicle'}
|
||||
</button>
|
||||
@@ -575,179 +580,249 @@ export default function Main() {
|
||||
<div className="vehicle-detail">
|
||||
{error && <div className="vehicle-error">{error}</div>}
|
||||
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
|
||||
{/* Current reading */}
|
||||
<div className="reading-display">
|
||||
<label>Current Reading</label>
|
||||
<div className={`reading-value ${
|
||||
!isConnected
|
||||
? 'reading-value--disconnected'
|
||||
: currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{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. */}
|
||||
<div className="vehicle-hero">
|
||||
<div className="vehicle-heading">
|
||||
<span className="vehicle-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.7"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h11v11H3z" />
|
||||
<path d="M14 10h4l3 3v4h-7z" />
|
||||
<circle cx="7" cy="19" r="2" />
|
||||
<circle cx="18" cy="19" r="2" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
<span className={`vehicle-status ${isFinalized ? 'vehicle-status--done' : 'vehicle-status--progress'}`}>
|
||||
● {isFinalized ? 'Finalized' : 'In progress'}
|
||||
</span>
|
||||
{isConnected && currentWeight !== null && !isNaN(currentWeight) && (
|
||||
<span className="vehicle-status vehicle-status--scale">◉ On Scale</span>
|
||||
)}
|
||||
</div>
|
||||
{isConnected && readingError && latestReading && (
|
||||
<div className="reading-error" title={latestReading.data}>
|
||||
{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. */}
|
||||
<div className="vehicle-hero-equalizer">
|
||||
<button
|
||||
type="button"
|
||||
className="equalizer-scale-toggle"
|
||||
onClick={() => setEqualizerScaleMode(m => m === 'fixed' ? 'auto' : 'fixed')}
|
||||
title={equalizerScaleMode === 'fixed'
|
||||
? 'Scaled to 60,000 kg - click for auto-scale'
|
||||
: 'Auto-scaled to visible readings - click for fixed 60,000 kg'}
|
||||
>
|
||||
{equalizerScaleMode === 'fixed' ? '60t' : 'AUTO'}
|
||||
</button>
|
||||
<div className="equalizer-bars">
|
||||
{equalizerSlots.map((slot, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`equalizer-bar ${
|
||||
slot.balanced === null ? '' : slot.balanced ? 'equalizer-bar--balanced' : 'equalizer-bar--unbalanced'
|
||||
}`}
|
||||
style={{ height: `${slot.heightPct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vehicle-hero-photo" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="weighing-section">
|
||||
{/* 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 ? (
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">First weighing</div>
|
||||
<div className="weight-card-value">
|
||||
{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. */}
|
||||
<div className="weight-card weight-card--reading">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Current Reading</div>
|
||||
<div className={`weight-card-value weight-card-value--reading ${
|
||||
!isConnected
|
||||
? 'reading-value--disconnected'
|
||||
: currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||
}
|
||||
</div>
|
||||
{firstWeighing && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(firstWeighing.date)}
|
||||
{firstWeighing.userName && ` (${firstWeighing.userName})`}
|
||||
{firstWeighing.manual && <span className="weight-manual-tag">manual</span>}
|
||||
{isConnected && readingError && latestReading && (
|
||||
<div className="reading-error" title={latestReading.data}>
|
||||
{readingError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tare */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">{selectedVehicle.tare} kg</div>
|
||||
{/* Measure/Manual entry, moved here from the action column:
|
||||
they act on THIS card's live value, so they sit beside it. */}
|
||||
{canMeasure && !isFinalized && (
|
||||
<div className="weight-card-reading-actions">
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
Measure
|
||||
</button>
|
||||
{!licenseAllowed && (
|
||||
<div className="weight-license-block">{licenseDetail}</div>
|
||||
)}
|
||||
{currentUser?.can_manually_measure && (
|
||||
<button
|
||||
className="weight-manual-btn"
|
||||
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
|
||||
>
|
||||
Manual entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tare. A lone reading is parked here by the backend, but a
|
||||
lighter second one moves it to Gross - the slot is shown
|
||||
plainly rather than hedged behind a neutral label. */}
|
||||
<div className="weight-card weight-card--tare">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '—'}
|
||||
</div>
|
||||
{selectedVehicle.tare != null && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.tare_date)}
|
||||
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
|
||||
{selectedVehicle.tare_manual && <span className="weight-manual-tag">manual</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gross */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
|
||||
{/* Gross */}
|
||||
<div className="weight-card weight-card--gross">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '—'}
|
||||
</div>
|
||||
{selectedVehicle.gross != null && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.gross_date)}
|
||||
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
|
||||
{selectedVehicle.gross_manual && <span className="weight-manual-tag">manual</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Measure */}
|
||||
{canMeasure && !isFinalized && (
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Scale</div>
|
||||
<div className={`weight-card-value ${
|
||||
currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight) ? `${currentWeight} kg` : '---'}
|
||||
</div>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
Measure
|
||||
</button>
|
||||
{!licenseAllowed && (
|
||||
<div className="weight-license-block">{licenseDetail}</div>
|
||||
)}
|
||||
{currentUser?.can_manually_measure && (
|
||||
<button
|
||||
className="weight-manual-btn"
|
||||
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
|
||||
>
|
||||
Manual entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Net */}
|
||||
{netWeight !== null && (
|
||||
<div className="weight-card weight-card--net">
|
||||
<div className="weight-card weight-card--net">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Net</div>
|
||||
<div className="weight-card-value">{netWeight} kg</div>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handlePrintNotice}
|
||||
onMouseEnter={prefetchA4Print}
|
||||
onFocus={prefetchA4Print}
|
||||
disabled={isPrinting}
|
||||
>
|
||||
{isPrinting ? 'Preparing...' : 'Print Notice'}
|
||||
</button>
|
||||
{/* Split button: the main action routes by
|
||||
the saved report's kind; the caret opens
|
||||
an explicit choice for starting the other
|
||||
kind. */}
|
||||
<div className="design-notice-split">
|
||||
<button
|
||||
className="weight-set-btn design-notice-main"
|
||||
onClick={handleDesignNotice}
|
||||
>
|
||||
Design Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn design-notice-caret"
|
||||
onClick={() => setShowDesignMenu(v => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showDesignMenu}
|
||||
title="Choose which editor to open"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{showDesignMenu && (
|
||||
<>
|
||||
{/* Click-away closes the menu. */}
|
||||
<div
|
||||
className="design-notice-backdrop"
|
||||
onClick={() => setShowDesignMenu(false)}
|
||||
/>
|
||||
<div className="design-notice-menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('matrix')}
|
||||
>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('a4')}
|
||||
>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="weight-card-value">
|
||||
{netWeight !== null ? `${netWeight} kg` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="weight-card weight-actions">
|
||||
{netWeight !== null && (
|
||||
<>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handlePrintNotice}
|
||||
onMouseEnter={prefetchA4Print}
|
||||
onFocus={prefetchA4Print}
|
||||
disabled={isPrinting}
|
||||
>
|
||||
{isPrinting ? 'Preparing...' : 'Print Notice'}
|
||||
</button>
|
||||
{/* Split button: the main action routes by
|
||||
the saved report's kind; the caret opens
|
||||
an explicit choice for starting the other
|
||||
kind. */}
|
||||
<div className="design-notice-split">
|
||||
<button
|
||||
className="weight-set-btn design-notice-main"
|
||||
onClick={handleDesignNotice}
|
||||
>
|
||||
Design Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn design-notice-caret"
|
||||
onClick={() => setShowDesignMenu(v => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showDesignMenu}
|
||||
title="Choose which editor to open"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{showDesignMenu && (
|
||||
<>
|
||||
{/* Click-away closes the menu. */}
|
||||
<div
|
||||
className="design-notice-backdrop"
|
||||
onClick={() => setShowDesignMenu(false)}
|
||||
/>
|
||||
<div className="design-notice-menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('matrix')}
|
||||
>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('a4')}
|
||||
>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra fields from nomenclatures */}
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<h3>Additional Data</h3>
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombination}
|
||||
/>
|
||||
<h3>Additional Data</h3>
|
||||
<div className="extra-fields">
|
||||
{visibleForSelected.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
|
||||
Reference in New Issue
Block a user