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:
@@ -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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
{/* 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 != null ? `${selectedVehicle.tare} kg` : '---'}
|
||||
</div>
|
||||
{selectedVehicle.tare_date && (
|
||||
<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>
|
||||
)}
|
||||
<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-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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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={handleSetGross}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.gross != null}
|
||||
onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
Set Gross
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user