Wire new features into the main screen and navigation

Main.jsx now parses readings through the selected protocol rather than
an inline regex, shows one Measure button instead of separate tare and
gross, and colours the live weight green when the scale reports stable
and red while it moves. A reading with no stability marker counts as
unstable, so an unrecognised format fails safe.

While only one weighing exists it appears as "First weighing" rather
than filling in the Tare card, because a lighter second reading can
still move it to gross and a value visibly jumping between cards would
read as a bug.

Header gains admin links to the scale monitor and licence screens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:58:26 +03:00
parent 6fe9f1820c
commit 0a1b7c31e2
4 changed files with 391 additions and 68 deletions
+15 -5
View File
@@ -9,6 +9,10 @@ import UserManager from './components/Users/UserManager';
import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
import { ScaleProtocolProvider } from './contexts/ScaleProtocolContext';
import ScaleMonitor from './components/ScaleMonitor/ScaleMonitor';
import { LicenseProvider } from './contexts/LicenseContext';
import LicenseManager from './components/License/LicenseManager';
// function MainApp() {
// const [selectedPort, setSelectedPort] = useState(null);
@@ -58,11 +62,17 @@ function AppContent() {
return (
<NomenclatureProvider>
<NomenclatureDataProvider>
<Routes>
<Route path="/" element={<Main />} />
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
</Routes>
<ScaleProtocolProvider>
<LicenseProvider>
<Routes>
<Route path="/" element={<Main />} />
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
<Route path="/scale-monitor" element={currentUser?.is_admin ? <ScaleMonitor /> : <Navigate to="/" />} />
<Route path="/license" element={currentUser?.is_admin ? <LicenseManager /> : <Navigate to="/" />} />
</Routes>
</LicenseProvider>
</ScaleProtocolProvider>
</NomenclatureDataProvider>
</NomenclatureProvider>
);
+18
View File
@@ -54,6 +54,24 @@ function Header() {
👥
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/scale-monitor')}
title="Scale Monitor"
>
📡
</button>
)}
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/license')}
title="Licence"
>
🔑
</button>
)}
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
+108
View File
@@ -374,3 +374,111 @@
grid-template-columns: 1fr;
}
}
.weight-manual-tag {
margin-left: 6px;
padding: 1px 5px;
border-radius: 3px;
background-color: #fff3cd;
color: #856404;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.weight-manual-btn {
margin-top: 6px;
padding: 4px 10px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
color: #666;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.weight-manual-btn:hover {
border-color: #667eea;
color: #667eea;
}
.manual-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.manual-content {
background: white;
border-radius: 12px;
padding: 24px;
width: 340px;
max-width: calc(100% - 32px);
}
.manual-content h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: #333;
}
.manual-field {
margin-bottom: 12px;
}
.manual-field label {
display: block;
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.manual-field input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
.manual-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.reading-value--balanced {
color: #2e7d32;
}
.reading-value--unbalanced {
color: #c62828;
}
.reading-error {
margin-top: 4px;
font-size: 11px;
color: #c62828;
font-style: italic;
}
.weight-license-block {
margin-top: 6px;
font-size: 11px;
color: #c62828;
font-style: italic;
}
+250 -63
View File
@@ -2,17 +2,26 @@ import { useState, useEffect, useMemo } 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 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('');
@@ -22,6 +31,8 @@ export default function Main() {
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
// Nomenclature data from context
const { definitions, entries } = useNomenclatureData();
@@ -33,19 +44,37 @@ export default function Main() {
);
const [extraData, setExtraData] = useState({});
// Current reading from COM port
// Data format: "Value: 36106.04" or just "36106.04"
const currentReading = readings.length > 0 ? readings[0].data : null;
const currentWeight = useMemo(() => {
if (!currentReading) return null;
// Extract numeric value - handle "Value: 123.45" or just "123.45"
const match = currentReading.match(/[\d.]+/);
if (match) {
const val = parseFloat(match[0]);
return isNaN(val) ? null : Math.round(val);
}
return null;
}, [currentReading]);
// 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;
// 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 = process.env.REACT_APP_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(
@@ -117,25 +146,47 @@ export default function Main() {
}
};
// Set tare
const handleSetTare = async () => {
// 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}/set-tare/`, { value: currentWeight });
} catch {
setError('Failed to set tare');
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
value: currentWeight,
station_key: stationKey,
});
} catch (err) {
setError(err.response?.data?.detail || 'Failed to record measurement');
}
};
// Set gross
const handleSetGross = async () => {
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
// 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}/set-gross/`, { value: currentWeight });
} catch {
setError('Failed to set gross');
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);
}
};
@@ -156,6 +207,15 @@ export default function Main() {
}
};
// Apply a saved combination - merge so non-lookup field values are preserved
const handleApplyCombination = (data) => {
setExtraData(prev => ({ ...prev, ...data }));
};
const handleApplyCombinationNew = (data) => {
setNewExtraData(prev => ({ ...prev, ...data }));
};
// Format date
const formatDate = (dateStr) => {
if (!dateStr) return '';
@@ -168,6 +228,32 @@ export default function Main() {
? selectedVehicle.gross - selectedVehicle.tare
: null;
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(() => {
@@ -196,6 +282,13 @@ export default function Main() {
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">
@@ -287,6 +380,10 @@ export default function Main() {
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombinationNew}
/>
<div className="extra-fields">
{vehicleNomenclatures.map(nom => (
<div key={nom.code} className="extra-field">
@@ -335,57 +432,101 @@ export default function Main() {
{/* Current reading */}
<div className="reading-display">
<label>Current Reading</label>
<div className={`reading-value ${isConnected ? '' : 'reading-value--disconnected'}`}>
<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')
}
</div>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
</div>
)}
</div>
{/* Weighing section */}
<div className="weighing-section">
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'}
</div>
{selectedVehicle.tare_date && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.tare_date)}
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
{/* 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` : '---'}
</div>
{firstWeighing && (
<div className="weight-card-meta">
{formatDate(firstWeighing.date)}
{firstWeighing.userName && ` (${firstWeighing.userName})`}
{firstWeighing.manual && <span className="weight-manual-tag">manual</span>}
</div>
)}
</div>
) : (
<>
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">{selectedVehicle.tare} kg</div>
<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>
)}
<button
className="weight-set-btn"
onClick={handleSetTare}
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.tare != null}
>
Set Tare
</button>
</div>
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'}
</div>
{selectedVehicle.gross_date && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.gross_date)}
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
<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>
)}
<button
className="weight-set-btn"
onClick={handleSetGross}
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.gross != null}
>
Set Gross
</button>
</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>
)}
{/* Net */}
{netWeight !== null && (
@@ -417,6 +558,10 @@ export default function Main() {
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<div className="extra-fields">
{vehicleNomenclatures.map(nom => (
<div key={nom.code} className="extra-field">
@@ -450,6 +595,48 @@ export default function Main() {
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>
);
}