Files
scalesApp/frontend/src/components/Main.jsx
T

901 lines
48 KiB
React

import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import { useScaleProtocols } from '../contexts/ScaleProtocolContext';
import { useLicense } from '../contexts/LicenseContext';
import { parseReading } from '../services/scaleProtocols';
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';
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 [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: '', driver_name: '' });
const [newExtraData, setNewExtraData] = useState({});
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [showDesignMenu, setShowDesignMenu] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
// Nomenclature data from context
const { definitions, entries } = useNomenclatureData();
const vehicleNomenclatures = useMemo(
() => Object.values(definitions)
.filter(d => d.applies_to === 'vehicle')
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)),
[definitions]
);
const [extraData, setExtraData] = useState({});
// A selector's value decides which other nomenclatures appear, so the visible
// set is derived per panel from that panel's own extra data.
const visibleForSelected = useMemo(
() => visibleNomenclatures(vehicleNomenclatures, definitions, extraData),
[vehicleNomenclatures, definitions, extraData]
);
const visibleForNew = useMemo(
() => visibleNomenclatures(vehicleNomenclatures, definitions, newExtraData),
[vehicleNomenclatures, definitions, newExtraData]
);
// Switching a selector clears whatever its PREVIOUS choice revealed, so a
// vehicle never carries data contradicting its own selector. Clearing on the
// previous value (rather than diffing visible sets) keeps a nomenclature that
// both choices reveal from being needlessly emptied.
const applySelectorChange = (setter) => (code, newValue) => {
setter(prev => {
const next = { ...prev, [code]: newValue };
if (definitions[code]?.kind === 'selector') {
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
delete next[revealed];
}
}
if (newValue === undefined) delete next[code];
return next;
});
};
const handleExtraChange = applySelectorChange(setExtraData);
const handleNewExtraChange = applySelectorChange(setNewExtraData);
// Never send a value the selector state hides - the serializer rejects those,
// and stale keys can survive on vehicles that predate the selector.
const prunedExtra = (data, visible) => {
const allowed = new Set(visible.map(n => n.code));
return Object.fromEntries(
Object.entries(data).filter(([code]) => allowed.has(code))
);
};
// Current reading from the serial bridge. The bridge is pure transport - how
// to read a line comes from the station protocol definition, so a new scale
// type never requires updating the bridge on the station PC.
const latestReading = readings.length > 0 ? readings[0] : null;
const parsed = useMemo(
() => parseReading(latestReading, selectedProtocol),
[latestReading, selectedProtocol]
);
const currentWeight = parsed.weight;
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
// closed tab would stop the scale). The UI only supplies the string.
useEffect(() => {
if (!selectedProtocol) return;
const serialUrl = import.meta.env.VITE_SERIAL_URL || 'http://localhost:5000';
fetch(serialUrl + '/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
command: selectedProtocol.poll_command || '',
interval: selectedProtocol.poll_interval || 0.2,
}),
}).catch(() => {
// Bridge not reachable - the SSE connection state already shows this.
});
}, [selectedProtocol]);
// Selected vehicle object (refreshes when vehicles list updates via SSE)
const selectedVehicle = useMemo(
() => vehicles.find(v => v.id === selectedVehicleId) || null,
[vehicles, selectedVehicleId]
);
// Filtered vehicles
const filteredVehicles = useMemo(() => {
if (!searchQuery.trim()) return vehicles;
const q = searchQuery.toLowerCase();
return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q));
}, [vehicles, searchQuery]);
// When vehicle selection changes, load its extra data
useEffect(() => {
if (selectedVehicle?.extra?.data) {
setExtraData({ ...selectedVehicle.extra.data });
} else {
setExtraData({});
}
}, [selectedVehicle]);
// Open new vehicle form
const handleNewVehicle = () => {
setSelectedVehicleId(null);
setShowNewForm(true);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
setNewExtraData({});
setError('');
};
// Cancel new vehicle form
const handleCancelNew = () => {
setShowNewForm(false);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({});
setError('');
};
// Create new vehicle
const handleCreateVehicle = async () => {
if (!newForm.vehicle_number.trim()) return;
setIsSaving(true);
setError('');
try {
const payload = {
vehicle_number: newForm.vehicle_number.trim(),
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);
const hasExtra = Object.values(visibleNewExtra).some(v => v !== undefined && v !== null && v !== '');
if (hasExtra) {
payload.extra = { data: visibleNewExtra };
}
const res = await api.post('/api/vehicles/', payload);
setSelectedVehicleId(res.data.id);
setShowNewForm(false);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({});
} catch (err) {
const data = err.response?.data;
const msg = data?.vehicle_number?.[0] || data?.detail || (typeof data === 'string' ? data : JSON.stringify(data)) || 'Failed to create vehicle';
setError(msg);
} finally {
setIsSaving(false);
}
};
// Record a weighing - the backend decides whether it is tare or gross
const handleMeasure = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
value: currentWeight,
station_key: stationKey,
});
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
}
};
// Record a weighing entered by hand - requires a note
const handleManualMeasure = async () => {
if (!selectedVehicle) return;
const value = parseInt(manualForm.value, 10);
if (isNaN(value)) {
setError('Enter a valid weight');
return;
}
if (!manualForm.note.trim()) {
setError('A note is required for a manual measurement');
return;
}
setIsSaving(true);
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
value,
manual: true,
note: manualForm.note.trim(),
station_key: stationKey,
});
setShowManualForm(false);
setManualForm({ value: '', note: '' });
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
} finally {
setIsSaving(false);
}
};
// Save extra data
const handleSaveExtra = async () => {
if (!selectedVehicle) return;
setIsSaving(true);
setError('');
try {
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
extra: { data: prunedExtra(extraData, visibleForSelected) },
});
} catch (err) {
const data = err.response?.data;
setError(typeof data === 'string' ? data : JSON.stringify(data) || 'Failed to save');
} finally {
setIsSaving(false);
}
};
// Apply a saved combination - merge so non-lookup field values are preserved
// A combination carries its selector value, so applying one must also drop
// whatever the previous selection revealed - otherwise a stale field survives
// the merge and the payload contradicts the new source.
const applyCombination = (setter) => (data) => {
setter(prev => {
const next = { ...prev, ...data };
for (const [code, nom] of Object.entries(definitions)) {
if (nom.kind !== 'selector') continue;
if (!(code in data) || data[code] === prev[code]) continue;
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
if (!(revealed in data)) delete next[revealed];
}
}
return next;
});
};
const handleApplyCombination = applyCombination(setExtraData);
const handleApplyCombinationNew = applyCombination(setNewExtraData);
// Format date
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
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;
const canMeasure = !!currentUser?.can_measure;
const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
// 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
// fetch-every-report-then-.find() pattern.
const fetchNoticeReport = async () => {
const res = await api.get('/api/reports/?name=notice_report');
return (res.data.results || res.data || [])[0] || null;
};
// Warm the A4 print chunk on hover/focus, so the click handler's dynamic
// import resolves from cache instead of blocking on a network round-trip
// between the operator's click and window.print(). Cheap insurance: the
// print dialog is not gated behind transient user activation the way
// window.open is, but the shorter the gap the less there is to go wrong on
// a slow station. Failure is ignored — the click path imports it anyway.
const prefetchA4Print = () => {
import('./ReportEditor/a4/print/printReport').catch(() => {});
};
// 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);
try {
const report = await fetchNoticeReport();
if (!report) {
setError('Report "notice_report" not found. Create it in the Report Editor first.');
return;
}
setShowNoticeReport(true);
} catch (err) {
console.error('Failed to print notice:', err);
setError('Failed to load the notice report');
} finally {
setIsPrinting(false);
}
};
// 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 (
<div>
<Header />
{licenseStatus && !licenseStatus.valid && (
<div className={`license-banner ${licenseStatus.in_grace ? '' : 'license-banner--blocked'}`}>
{licenseStatus.in_grace
? <>This installation is not licensed. Everything keeps working for <strong>{licenseStatus.grace_days_left} more days</strong>.</>
: <>This installation is not licensed. Only {licenseStatus.stations} station can measure.</>}
</div>
)}
<div className="main">
{/* Left panel - Vehicle list */}
<div className="main-left">
<div className="vehicle-list-header">
<button
className="vehicle-add-btn"
onClick={handleNewVehicle}
>
+ New Vehicle
</button>
<input
className="vehicle-search"
type="text"
placeholder="Search..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="vehicle-list">
{filteredVehicles.map(v => (
<div
key={v.id}
className={`vehicle-list-item ${selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''}`}
onClick={() => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); }}
>
<span className="vehicle-list-number">{v.vehicle_number}</span>
{v.tare != null && (
<span className="vehicle-list-weight">{v.tare} kg</span>
)}
</div>
))}
{filteredVehicles.length === 0 && (
<div className="vehicle-list-empty">No vehicles found</div>
)}
</div>
</div>
{/* Right panel - Vehicle detail or New form */}
<div className="main-right">
{showNewForm ? (
<div className="vehicle-detail">
{error && <div className="vehicle-error">{error}</div>}
<h2 className="vehicle-detail-title">New Vehicle</h2>
<div className="vehicle-form-section">
<div className="vehicle-new-fields">
<div className="extra-field">
<label>Vehicle Number *</label>
<input
type="text"
value={newForm.vehicle_number}
onChange={e => setNewForm(prev => ({ ...prev, vehicle_number: e.target.value }))}
disabled={isSaving}
autoFocus
/>
</div>
<div className="extra-field">
<label>Trailer 1</label>
<input
type="text"
value={newForm.trailer1_number}
onChange={e => setNewForm(prev => ({ ...prev, trailer1_number: e.target.value }))}
disabled={isSaving}
/>
</div>
<div className="extra-field">
<label>Trailer 2</label>
<input
type="text"
value={newForm.trailer2_number}
onChange={e => setNewForm(prev => ({ ...prev, trailer2_number: e.target.value }))}
disabled={isSaving}
/>
</div>
<div className="extra-field">
<label>Driver PID</label>
<input
type="text"
value={newForm.driver_pid}
onChange={e => setNewForm(prev => ({ ...prev, driver_pid: e.target.value }))}
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">
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombinationNew}
/>
<h3>Additional Data</h3>
<div className="extra-fields">
{visibleForNew.map(nom => (
<div key={nom.code} className="extra-field">
<NomenclatureDropdown
nomenclatureCode={nom.code}
value={newExtraData[nom.code]}
onChange={val => handleNewExtraChange(nom.code, val)}
disabled={isSaving}
/>
</div>
))}
</div>
</div>
)}
<div className="vehicle-form-actions">
<button
className="vehicle-form-cancel"
onClick={handleCancelNew}
disabled={isSaving}
>
Cancel
</button>
<button
className="extra-save-btn"
onClick={handleCreateVehicle}
disabled={isSaving || !newForm.vehicle_number.trim() || !newForm.driver_name.trim()}
>
{isSaving ? 'Creating...' : 'Create Vehicle'}
</button>
</div>
</div>
) : !selectedVehicle ? (
<div className="main-placeholder">
Select a vehicle or create a new one
</div>
) : (
<div className="vehicle-detail">
{error && <div className="vehicle-error">{error}</div>}
{/* 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>
{/* 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 - 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">
{/* 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>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
</div>
)}
</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>
{/* 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>
</div>
{/* 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 !== 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 &amp; PDF</span>
</button>
</div>
</>
)}
</div>
</>
)}
</div>
</div>
{/* Extra fields from nomenclatures */}
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<h3>Additional Data</h3>
<div className="extra-fields">
{visibleForSelected.map(nom => (
<div key={nom.code} className="extra-field">
<NomenclatureDropdown
nomenclatureCode={nom.code}
value={extraData[nom.code]}
onChange={val => handleExtraChange(nom.code, val)}
/>
</div>
))}
</div>
<button
className="extra-save-btn"
onClick={handleSaveExtra}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Save Extra Data'}
</button>
</div>
)}
</div>
)}
</div>
</div>
{showNoticeReport && vehicleReportData && (
<ReportPreviewModal
vehicleData={vehicleReportData}
onClose={() => setShowNoticeReport(false)}
/>
)}
{showManualForm && (
<div className="manual-overlay" onClick={() => setShowManualForm(false)}>
<div className="manual-content" onClick={e => e.stopPropagation()}>
<h3>Manual measurement</h3>
<div className="manual-field">
<label>Weight (kg)</label>
<input
type="number"
value={manualForm.value}
onChange={e => setManualForm(prev => ({ ...prev, value: e.target.value }))}
autoFocus
/>
</div>
<div className="manual-field">
<label>Reason</label>
<input
type="text"
value={manualForm.note}
onChange={e => setManualForm(prev => ({ ...prev, note: e.target.value }))}
placeholder="Why is this entered by hand?"
/>
</div>
<div className="manual-actions">
<button
className="vehicle-form-cancel"
onClick={() => setShowManualForm(false)}
disabled={isSaving}
>
Cancel
</button>
<button
className="weight-set-btn"
onClick={handleManualMeasure}
disabled={isSaving}
>
{isSaving ? 'Saving...' : 'Record'}
</button>
</div>
</div>
</div>
)}
</div>
);
}