From 0a1b7c31e2f67ce10f9cd97c50bf28abd432655c Mon Sep 17 00:00:00 2001 From: kikimor Date: Fri, 18 Sep 2026 18:58:26 +0300 Subject: [PATCH] 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 --- frontend/src/App.jsx | 20 +- frontend/src/components/Header.jsx | 18 ++ frontend/src/components/Main.css | 108 ++++++++++ frontend/src/components/Main.jsx | 313 +++++++++++++++++++++++------ 4 files changed, 391 insertions(+), 68 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 101c83d..5f01ec1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - - } /> - : } /> - : } /> - + + + + } /> + : } /> + : } /> + : } /> + : } /> + + + ); diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 4e36dfc..ed77267 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -54,6 +54,24 @@ function Header() { 👥 )} + {currentUser?.is_admin && ( + + )} + {currentUser?.is_admin && ( + + )}

ScalesApp - Real-time Data Monitor

diff --git a/frontend/src/components/Main.css b/frontend/src/components/Main.css index ad35157..466a7a3 100644 --- a/frontend/src/components/Main.css +++ b/frontend/src/components/Main.css @@ -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; +} diff --git a/frontend/src/components/Main.jsx b/frontend/src/components/Main.jsx index ade6676..06a44b3 100644 --- a/frontend/src/components/Main.jsx +++ b/frontend/src/components/Main.jsx @@ -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 (
+ {licenseStatus && !licenseStatus.valid && ( +
+ {licenseStatus.in_grace + ? <>This installation is not licensed. Everything keeps working for {licenseStatus.grace_days_left} more days. + : <>This installation is not licensed. Only {licenseStatus.stations} station can measure.} +
+ )}
{/* Left panel - Vehicle list */}
@@ -287,6 +380,10 @@ export default function Main() { {vehicleNomenclatures.length > 0 && (

Additional Data

+
{vehicleNomenclatures.map(nom => (
@@ -335,57 +432,101 @@ export default function Main() { {/* Current reading */}
-
+
{currentWeight !== null && !isNaN(currentWeight) ? `${currentWeight} kg` : (isConnected ? 'Waiting...' : 'Disconnected') }
+ {isConnected && readingError && latestReading && ( +
+ {readingError} +
+ )}
{/* Weighing section */}
- {/* Tare */} -
-
Tare
-
- {selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'} -
- {selectedVehicle.tare_date && ( -
- {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 ? ( +
+
First weighing
+
+ {firstWeighing ? `${firstWeighing.value} kg` : '---'} +
+ {firstWeighing && ( +
+ {formatDate(firstWeighing.date)} + {firstWeighing.userName && ` (${firstWeighing.userName})`} + {firstWeighing.manual && manual} +
+ )} +
+ ) : ( + <> + {/* Tare */} +
+
Tare
+
{selectedVehicle.tare} kg
+
+ {formatDate(selectedVehicle.tare_date)} + {selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`} + {selectedVehicle.tare_manual && manual} +
- )} - -
- {/* Gross */} -
-
Gross
-
- {selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'} -
- {selectedVehicle.gross_date && ( -
- {formatDate(selectedVehicle.gross_date)} - {selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`} + {/* Gross */} +
+
Gross
+
{selectedVehicle.gross} kg
+
+ {formatDate(selectedVehicle.gross_date)} + {selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`} + {selectedVehicle.gross_manual && manual} +
- )} - -
+ + )} + + {/* Measure */} + {canMeasure && !isFinalized && ( +
+
Scale
+
+ {currentWeight !== null && !isNaN(currentWeight) ? `${currentWeight} kg` : '---'} +
+ + {!licenseAllowed && ( +
{licenseDetail}
+ )} + {currentUser?.can_manually_measure && ( + + )} +
+ )} {/* Net */} {netWeight !== null && ( @@ -417,6 +558,10 @@ export default function Main() { {vehicleNomenclatures.length > 0 && (

Additional Data

+
{vehicleNomenclatures.map(nom => (
@@ -450,6 +595,48 @@ export default function Main() { onClose={() => setShowNoticeReport(false)} /> )} + + {showManualForm && ( +
setShowManualForm(false)}> +
e.stopPropagation()}> +

Manual measurement

+
+ + setManualForm(prev => ({ ...prev, value: e.target.value }))} + autoFocus + /> +
+
+ + setManualForm(prev => ({ ...prev, note: e.target.value }))} + placeholder="Why is this entered by hand?" + /> +
+
+ + +
+
+
+ )}
); }