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?" + /> +
+
+ + +
+
+
+ )}
); }