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:
+15
-5
@@ -9,6 +9,10 @@ import UserManager from './components/Users/UserManager';
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||||
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
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() {
|
// function MainApp() {
|
||||||
// const [selectedPort, setSelectedPort] = useState(null);
|
// const [selectedPort, setSelectedPort] = useState(null);
|
||||||
@@ -58,11 +62,17 @@ function AppContent() {
|
|||||||
return (
|
return (
|
||||||
<NomenclatureProvider>
|
<NomenclatureProvider>
|
||||||
<NomenclatureDataProvider>
|
<NomenclatureDataProvider>
|
||||||
<Routes>
|
<ScaleProtocolProvider>
|
||||||
<Route path="/" element={<Main />} />
|
<LicenseProvider>
|
||||||
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
|
<Routes>
|
||||||
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
|
<Route path="/" element={<Main />} />
|
||||||
</Routes>
|
<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>
|
</NomenclatureDataProvider>
|
||||||
</NomenclatureProvider>
|
</NomenclatureProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,6 +54,24 @@ function Header() {
|
|||||||
👥
|
👥
|
||||||
</button>
|
</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>
|
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
|
|||||||
@@ -374,3 +374,111 @@
|
|||||||
grid-template-columns: 1fr;
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import useSerialData from '../hooks/useSerialData';
|
import useSerialData from '../hooks/useSerialData';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
import { useNomenclatures } from '../contexts/NomenclatureContext';
|
||||||
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
|
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 NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
|
||||||
import ReportPreviewModal from './ReportPreviewModal';
|
import ReportPreviewModal from './ReportPreviewModal';
|
||||||
|
import ExtraCombinationsTable from './ExtraCombinationsTable';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import './Main.css';
|
import './Main.css';
|
||||||
|
import './License/LicenseManager.css';
|
||||||
|
|
||||||
export default function Main() {
|
export default function Main() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { readings, isConnected } = useSerialData();
|
const { readings, isConnected } = useSerialData();
|
||||||
const { vehicles } = useNomenclatures();
|
const { vehicles } = useNomenclatures();
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const { selectedProtocol } = useScaleProtocols();
|
||||||
|
const { allowed: licenseAllowed, detail: licenseDetail, stationKey, status: licenseStatus } = useLicense();
|
||||||
|
|
||||||
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
|
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -22,6 +31,8 @@ export default function Main() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [showNoticeReport, setShowNoticeReport] = useState(false);
|
const [showNoticeReport, setShowNoticeReport] = useState(false);
|
||||||
|
const [showManualForm, setShowManualForm] = useState(false);
|
||||||
|
const [manualForm, setManualForm] = useState({ value: '', note: '' });
|
||||||
|
|
||||||
// Nomenclature data from context
|
// Nomenclature data from context
|
||||||
const { definitions, entries } = useNomenclatureData();
|
const { definitions, entries } = useNomenclatureData();
|
||||||
@@ -33,19 +44,37 @@ export default function Main() {
|
|||||||
);
|
);
|
||||||
const [extraData, setExtraData] = useState({});
|
const [extraData, setExtraData] = useState({});
|
||||||
|
|
||||||
// Current reading from COM port
|
// Current reading from the serial bridge. The bridge is pure transport - how
|
||||||
// Data format: "Value: 36106.04" or just "36106.04"
|
// to read a line comes from the station protocol definition, so a new scale
|
||||||
const currentReading = readings.length > 0 ? readings[0].data : null;
|
// type never requires updating the bridge on the station PC.
|
||||||
const currentWeight = useMemo(() => {
|
const latestReading = readings.length > 0 ? readings[0] : null;
|
||||||
if (!currentReading) return null;
|
|
||||||
// Extract numeric value - handle "Value: 123.45" or just "123.45"
|
const parsed = useMemo(
|
||||||
const match = currentReading.match(/[\d.]+/);
|
() => parseReading(latestReading, selectedProtocol),
|
||||||
if (match) {
|
[latestReading, selectedProtocol]
|
||||||
const val = parseFloat(match[0]);
|
);
|
||||||
return isNaN(val) ? null : Math.round(val);
|
const currentWeight = parsed.weight;
|
||||||
}
|
const isBalanced = parsed.balanced;
|
||||||
return null;
|
const readingError = parsed.error;
|
||||||
}, [currentReading]);
|
|
||||||
|
// 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)
|
// Selected vehicle object (refreshes when vehicles list updates via SSE)
|
||||||
const selectedVehicle = useMemo(
|
const selectedVehicle = useMemo(
|
||||||
@@ -117,25 +146,47 @@ export default function Main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set tare
|
// Record a weighing - the backend decides whether it is tare or gross
|
||||||
const handleSetTare = async () => {
|
const handleMeasure = async () => {
|
||||||
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
|
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/vehicles/${selectedVehicle.id}/set-tare/`, { value: currentWeight });
|
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
|
||||||
} catch {
|
value: currentWeight,
|
||||||
setError('Failed to set tare');
|
station_key: stationKey,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Failed to record measurement');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set gross
|
// Record a weighing entered by hand - requires a note
|
||||||
const handleSetGross = async () => {
|
const handleManualMeasure = async () => {
|
||||||
if (!selectedVehicle || currentWeight === null || isNaN(currentWeight)) return;
|
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('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/vehicles/${selectedVehicle.id}/set-gross/`, { value: currentWeight });
|
await api.post(`/api/vehicles/${selectedVehicle.id}/measure/`, {
|
||||||
} catch {
|
value,
|
||||||
setError('Failed to set gross');
|
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
|
// Format date
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
@@ -168,6 +228,32 @@ export default function Main() {
|
|||||||
? selectedVehicle.gross - selectedVehicle.tare
|
? selectedVehicle.gross - selectedVehicle.tare
|
||||||
: null;
|
: 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
|
// Vehicle data shaped for report preview/design
|
||||||
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
|
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
|
||||||
const vehicleReportData = useMemo(() => {
|
const vehicleReportData = useMemo(() => {
|
||||||
@@ -196,6 +282,13 @@ export default function Main() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<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">
|
<div className="main">
|
||||||
{/* Left panel - Vehicle list */}
|
{/* Left panel - Vehicle list */}
|
||||||
<div className="main-left">
|
<div className="main-left">
|
||||||
@@ -287,6 +380,10 @@ export default function Main() {
|
|||||||
{vehicleNomenclatures.length > 0 && (
|
{vehicleNomenclatures.length > 0 && (
|
||||||
<div className="extra-section">
|
<div className="extra-section">
|
||||||
<h3>Additional Data</h3>
|
<h3>Additional Data</h3>
|
||||||
|
<ExtraCombinationsTable
|
||||||
|
nomenclatures={vehicleNomenclatures}
|
||||||
|
onApply={handleApplyCombinationNew}
|
||||||
|
/>
|
||||||
<div className="extra-fields">
|
<div className="extra-fields">
|
||||||
{vehicleNomenclatures.map(nom => (
|
{vehicleNomenclatures.map(nom => (
|
||||||
<div key={nom.code} className="extra-field">
|
<div key={nom.code} className="extra-field">
|
||||||
@@ -335,57 +432,101 @@ export default function Main() {
|
|||||||
{/* Current reading */}
|
{/* Current reading */}
|
||||||
<div className="reading-display">
|
<div className="reading-display">
|
||||||
<label>Current Reading</label>
|
<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 !== null && !isNaN(currentWeight)
|
||||||
? `${currentWeight} kg`
|
? `${currentWeight} kg`
|
||||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{isConnected && readingError && latestReading && (
|
||||||
|
<div className="reading-error" title={latestReading.data}>
|
||||||
|
{readingError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weighing section */}
|
{/* Weighing section */}
|
||||||
<div className="weighing-section">
|
<div className="weighing-section">
|
||||||
{/* Tare */}
|
{/* Before both weights exist, the first reading's role is not
|
||||||
<div className="weight-card">
|
final yet - show it neutrally so it doesn't appear to jump. */}
|
||||||
<div className="weight-card-header">Tare</div>
|
{selectedVehicle.tare == null || selectedVehicle.gross == null ? (
|
||||||
<div className="weight-card-value">
|
<div className="weight-card">
|
||||||
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '---'}
|
<div className="weight-card-header">First weighing</div>
|
||||||
</div>
|
<div className="weight-card-value">
|
||||||
{selectedVehicle.tare_date && (
|
{firstWeighing ? `${firstWeighing.value} kg` : '---'}
|
||||||
<div className="weight-card-meta">
|
</div>
|
||||||
{formatDate(selectedVehicle.tare_date)}
|
{firstWeighing && (
|
||||||
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
|
<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>
|
</div>
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="weight-set-btn"
|
|
||||||
onClick={handleSetTare}
|
|
||||||
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.tare != null}
|
|
||||||
>
|
|
||||||
Set Tare
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gross */}
|
{/* Gross */}
|
||||||
<div className="weight-card">
|
<div className="weight-card">
|
||||||
<div className="weight-card-header">Gross</div>
|
<div className="weight-card-header">Gross</div>
|
||||||
<div className="weight-card-value">
|
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
|
||||||
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '---'}
|
<div className="weight-card-meta">
|
||||||
</div>
|
{formatDate(selectedVehicle.gross_date)}
|
||||||
{selectedVehicle.gross_date && (
|
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
|
||||||
<div className="weight-card-meta">
|
{selectedVehicle.gross_manual && <span className="weight-manual-tag">manual</span>}
|
||||||
{formatDate(selectedVehicle.gross_date)}
|
</div>
|
||||||
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
<button
|
)}
|
||||||
className="weight-set-btn"
|
|
||||||
onClick={handleSetGross}
|
{/* Measure */}
|
||||||
disabled={currentWeight === null || isNaN(currentWeight) || selectedVehicle?.gross != null}
|
{canMeasure && !isFinalized && (
|
||||||
>
|
<div className="weight-card">
|
||||||
Set Gross
|
<div className="weight-card-header">Scale</div>
|
||||||
</button>
|
<div className={`weight-card-value ${
|
||||||
</div>
|
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 */}
|
{/* Net */}
|
||||||
{netWeight !== null && (
|
{netWeight !== null && (
|
||||||
@@ -417,6 +558,10 @@ export default function Main() {
|
|||||||
{vehicleNomenclatures.length > 0 && (
|
{vehicleNomenclatures.length > 0 && (
|
||||||
<div className="extra-section">
|
<div className="extra-section">
|
||||||
<h3>Additional Data</h3>
|
<h3>Additional Data</h3>
|
||||||
|
<ExtraCombinationsTable
|
||||||
|
nomenclatures={vehicleNomenclatures}
|
||||||
|
onApply={handleApplyCombination}
|
||||||
|
/>
|
||||||
<div className="extra-fields">
|
<div className="extra-fields">
|
||||||
{vehicleNomenclatures.map(nom => (
|
{vehicleNomenclatures.map(nom => (
|
||||||
<div key={nom.code} className="extra-field">
|
<div key={nom.code} className="extra-field">
|
||||||
@@ -450,6 +595,48 @@ export default function Main() {
|
|||||||
onClose={() => setShowNoticeReport(false)}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user