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.>}
+
- {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 ? (
+