import { useState, useEffect, useMemo, useRef } 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 { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility'; import { buildVehicleReportData } from '../services/vehicleReportData'; 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(''); const [showNewForm, setShowNewForm] = useState(false); const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' }); const [newExtraData, setNewExtraData] = useState({}); const [error, setError] = useState(''); const [isSaving, setIsSaving] = useState(false); const [showNoticeReport, setShowNoticeReport] = useState(false); const [isPrinting, setIsPrinting] = useState(false); const [showDesignMenu, setShowDesignMenu] = useState(false); const [showManualForm, setShowManualForm] = useState(false); const [manualForm, setManualForm] = useState({ value: '', note: '' }); // Nomenclature data from context const { definitions, entries } = useNomenclatureData(); const vehicleNomenclatures = useMemo( () => Object.values(definitions) .filter(d => d.applies_to === 'vehicle') .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)), [definitions] ); const [extraData, setExtraData] = useState({}); // A selector's value decides which other nomenclatures appear, so the visible // set is derived per panel from that panel's own extra data. const visibleForSelected = useMemo( () => visibleNomenclatures(vehicleNomenclatures, definitions, extraData), [vehicleNomenclatures, definitions, extraData] ); const visibleForNew = useMemo( () => visibleNomenclatures(vehicleNomenclatures, definitions, newExtraData), [vehicleNomenclatures, definitions, newExtraData] ); // Switching a selector clears whatever its PREVIOUS choice revealed, so a // vehicle never carries data contradicting its own selector. Clearing on the // previous value (rather than diffing visible sets) keeps a nomenclature that // both choices reveal from being needlessly emptied. const applySelectorChange = (setter) => (code, newValue) => { setter(prev => { const next = { ...prev, [code]: newValue }; if (definitions[code]?.kind === 'selector') { for (const revealed of codesRevealedBy(definitions, code, prev[code])) { delete next[revealed]; } } if (newValue === undefined) delete next[code]; return next; }); }; const handleExtraChange = applySelectorChange(setExtraData); const handleNewExtraChange = applySelectorChange(setNewExtraData); // Never send a value the selector state hides - the serializer rejects those, // and stale keys can survive on vehicles that predate the selector. const prunedExtra = (data, visible) => { const allowed = new Set(visible.map(n => n.code)); return Object.fromEntries( Object.entries(data).filter(([code]) => allowed.has(code)) ); }; // 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; // Equalizer bar history: last 40 readings, oldest first, newest at the // right. A ticking interval, not a useEffect keyed on value changes - a // static weight (scale settled, or nothing on it) would otherwise never // push a new bar at all, since the effect only reruns when the value // itself changes. Sampling on a timer keeps the strip moving continuously // whether or not the reading is currently changing. const EQUALIZER_BAR_COUNT = 40; const EQUALIZER_SAMPLE_INTERVAL_MS = 250; const EQUALIZER_FIXED_MAX_KG = 60000; const [equalizerBars, setEqualizerBars] = useState([]); // 'fixed' makes bar height comparable across vehicles/sessions; 'auto' // scales to whatever is on screen right now, so a light load still fills // the card. Not persisted - always starts on 'fixed' per session. const [equalizerScaleMode, setEqualizerScaleMode] = useState('fixed'); const latestReadingRef = useRef({ weight: null, balanced: null }); latestReadingRef.current = { weight: currentWeight, balanced: isBalanced }; useEffect(() => { const id = setInterval(() => { const { weight, balanced } = latestReadingRef.current; if (weight === null || isNaN(weight)) return; setEqualizerBars(prev => [...prev, { value: weight, balanced }].slice(-EQUALIZER_BAR_COUNT)); }, EQUALIZER_SAMPLE_INTERVAL_MS); return () => clearInterval(id); }, []); // Padded to a fixed 40 slots (empty ones render as zero-height bars). // 'fixed' scales against EQUALIZER_FIXED_MAX_KG (clamped at 100% above // it); 'auto' scales against the tallest bar currently visible, so the // row re-scales as the window slides instead of clipping. const equalizerSlots = useMemo(() => { const maxValue = equalizerScaleMode === 'fixed' ? EQUALIZER_FIXED_MAX_KG : Math.max(1, ...equalizerBars.map(b => Math.abs(b.value))); const padding = Array(Math.max(0, EQUALIZER_BAR_COUNT - equalizerBars.length)).fill(null); return [...padding, ...equalizerBars].map(bar => ({ balanced: bar?.balanced ?? null, heightPct: bar ? Math.min(100, Math.max(6, (Math.abs(bar.value) / maxValue) * 100)) : 0, })); }, [equalizerBars, equalizerScaleMode]); // 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 = import.meta.env.VITE_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( () => vehicles.find(v => v.id === selectedVehicleId) || null, [vehicles, selectedVehicleId] ); // Filtered vehicles const filteredVehicles = useMemo(() => { if (!searchQuery.trim()) return vehicles; const q = searchQuery.toLowerCase(); return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q)); }, [vehicles, searchQuery]); // When vehicle selection changes, load its extra data useEffect(() => { if (selectedVehicle?.extra?.data) { setExtraData({ ...selectedVehicle.extra.data }); } else { setExtraData({}); } }, [selectedVehicle]); // Open new vehicle form const handleNewVehicle = () => { setSelectedVehicleId(null); setShowNewForm(true); setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' }); setNewExtraData({}); setError(''); }; // Cancel new vehicle form const handleCancelNew = () => { setShowNewForm(false); setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' }); setNewExtraData({}); setError(''); }; // Create new vehicle const handleCreateVehicle = async () => { if (!newForm.vehicle_number.trim()) return; setIsSaving(true); setError(''); try { const payload = { vehicle_number: newForm.vehicle_number.trim(), trailer1_number: newForm.trailer1_number.trim() || null, trailer2_number: newForm.trailer2_number.trim() || null, driver_pid: newForm.driver_pid.trim() || null, driver_name: newForm.driver_name.trim(), }; // Include extra data if any values set const visibleNewExtra = prunedExtra(newExtraData, visibleForNew); const hasExtra = Object.values(visibleNewExtra).some(v => v !== undefined && v !== null && v !== ''); if (hasExtra) { payload.extra = { data: visibleNewExtra }; } const res = await api.post('/api/vehicles/', payload); setSelectedVehicleId(res.data.id); setShowNewForm(false); setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' }); setNewExtraData({}); } catch (err) { const data = err.response?.data; const msg = data?.vehicle_number?.[0] || data?.detail || (typeof data === 'string' ? data : JSON.stringify(data)) || 'Failed to create vehicle'; setError(msg); } finally { setIsSaving(false); } }; // 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}/measure/`, { value: currentWeight, station_key: stationKey, }); } catch (err) { setError(err.response?.data?.detail || 'Failed to record measurement'); } }; // 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}/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); } }; // Save extra data const handleSaveExtra = async () => { if (!selectedVehicle) return; setIsSaving(true); setError(''); try { await api.patch(`/api/vehicles/${selectedVehicle.id}/`, { extra: { data: prunedExtra(extraData, visibleForSelected) }, }); } catch (err) { const data = err.response?.data; setError(typeof data === 'string' ? data : JSON.stringify(data) || 'Failed to save'); } finally { setIsSaving(false); } }; // Apply a saved combination - merge so non-lookup field values are preserved // A combination carries its selector value, so applying one must also drop // whatever the previous selection revealed - otherwise a stale field survives // the merge and the payload contradicts the new source. const applyCombination = (setter) => (data) => { setter(prev => { const next = { ...prev, ...data }; for (const [code, nom] of Object.entries(definitions)) { if (nom.kind !== 'selector') continue; if (!(code in data) || data[code] === prev[code]) continue; for (const revealed of codesRevealedBy(definitions, code, prev[code])) { if (!(revealed in data)) delete next[revealed]; } } return next; }); }; const handleApplyCombination = applyCombination(setExtraData); const handleApplyCombinationNew = applyCombination(setNewExtraData); // Format date const formatDate = (dateStr) => { if (!dateStr) return ''; const d = new Date(dateStr); return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; // Net weight const netWeight = (selectedVehicle?.tare != null && selectedVehicle?.gross != null) ? selectedVehicle.gross - selectedVehicle.tare : null; const canMeasure = !!currentUser?.can_measure; const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null; // Vehicle data shaped for report preview/design (shared with History) const vehicleReportData = useMemo( () => buildVehicleReportData(selectedVehicle, definitions, entries), [selectedVehicle, definitions, entries] ); // Look up the saved notice report by name. Server-side filter — ?name= is a // real filter now that django-filter is configured, replacing the old // fetch-every-report-then-.find() pattern. const fetchNoticeReport = async () => { const res = await api.get('/api/reports/?name=notice_report'); return (res.data.results || res.data || [])[0] || null; }; // Warm the A4 print chunk on hover/focus, so the click handler's dynamic // import resolves from cache instead of blocking on a network round-trip // between the operator's click and window.print(). Cheap insurance: the // print dialog is not gated behind transient user activation the way // window.open is, but the shorter the gap the less there is to go wrong on // a slow station. Failure is ignored — the click path imports it anyway. const prefetchA4Print = () => { import('./ReportEditor/a4/print/printReport').catch(() => {}); }; // Print Notice always opens the unified preview modal now — the modal // itself fetches both the matrix and a4 notice_report variants and decides // what Print/Download do based on whichever kind is selected in its own // switcher. Previously an a4 report skipped the modal and went straight to // window.print(); that bypass is gone so both kinds get the same // preview/print/download/mark-notice-printed flow. const handlePrintNotice = async () => { if (isPrinting) return; setIsPrinting(true); try { const report = await fetchNoticeReport(); if (!report) { setError('Report "notice_report" not found. Create it in the Report Editor first.'); return; } setShowNoticeReport(true); } catch (err) { console.error('Failed to print notice:', err); setError('Failed to load the notice report'); } finally { setIsPrinting(false); } }; // Design Notice routes to the editor that can actually edit the saved // report. A matrix report opened in the mm editor would read its character // coordinates as millimetres, so the kind decides the route, not a default. const handleDesignNotice = async () => { const state = { reportName: 'notice_report', vehicleData: vehicleReportData }; try { const report = await fetchNoticeReport(); navigate(report?.kind === 'a4' ? '/report-editor-a4' : '/report-editor', { state }); } catch (err) { // Nothing saved yet, or the lookup failed — the matrix editor is the // existing default and pre-fills the name so Save creates it. console.error('Failed to resolve notice report kind:', err); navigate('/report-editor', { state }); } }; /** * Open a chosen editor regardless of what is saved. * * The automatic route above is right for editing an existing notice; this is * for deliberately starting the OTHER kind. Opening a report in the wrong * editor reinterprets its coordinates — character cells as millimetres, or * the reverse — so a mismatch is confirmed rather than done silently. */ const handleDesignAs = async (kind) => { setShowDesignMenu(false); const state = { reportName: 'notice_report', vehicleData: vehicleReportData }; const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor'; let saved = null; try { saved = await fetchNoticeReport(); } catch (err) { console.error('Failed to resolve notice report kind:', err); } if (saved && saved.kind !== kind) { const savedLabel = saved.kind === 'a4' ? 'A4' : 'matrix'; const targetLabel = kind === 'a4' ? 'A4' : 'matrix'; const ok = window.confirm( `"notice_report" is saved as a ${savedLabel} report.\n\n` + `Opening it in the ${targetLabel} editor will read its positions in the ` + `wrong units, and saving would overwrite the ${savedLabel} layout.\n\n` + `Open the ${targetLabel} editor anyway?` ); if (!ok) return; } navigate(route, { state }); }; 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 */}
setSearchQuery(e.target.value)} />
{filteredVehicles.map(v => (
{ setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); }} > {v.vehicle_number} {v.tare != null && ( {v.tare} kg )}
))} {filteredVehicles.length === 0 && (
No vehicles found
)}
{/* Right panel - Vehicle detail or New form */}
{showNewForm ? (
{error &&
{error}
}

New Vehicle

setNewForm(prev => ({ ...prev, vehicle_number: e.target.value }))} disabled={isSaving} autoFocus />
setNewForm(prev => ({ ...prev, trailer1_number: e.target.value }))} disabled={isSaving} />
setNewForm(prev => ({ ...prev, trailer2_number: e.target.value }))} disabled={isSaving} />
setNewForm(prev => ({ ...prev, driver_pid: e.target.value }))} disabled={isSaving} />
setNewForm(prev => ({ ...prev, driver_name: e.target.value }))} disabled={isSaving} />
{vehicleNomenclatures.length > 0 && (

Additional Data

{visibleForNew.map(nom => (
handleNewExtraChange(nom.code, val)} disabled={isSaving} />
))}
)}
) : !selectedVehicle ? (
Select a vehicle or create a new one
) : (
{error &&
{error}
} {/* Header bar: icon, plate number, derived status pills, and (cockpit only) a photo. Both pills come from real state, not decoration: "on scale" is this vehicle having a live reading right now, and the weighing status is the same isFinalized used by the cards below. */}

{selectedVehicle.vehicle_number}

● {isFinalized ? 'Finalized' : 'In progress'} {isConnected && currentWeight !== null && !isNaN(currentWeight) && ( ◉ On Scale )}
{/* Equalizer: last 40 live readings, oldest to newest left to right. Green when the last reading was stable, rose when it wasn't. The corner button switches between scaling against a fixed 60t ceiling (comparable across vehicles) and scaling to whatever is on screen right now (fills the strip for any load). No border/background of its own - it sits directly on the hero bar between the pills and the photo. */}
{equalizerSlots.map((slot, i) => ( ))}
{/* Weighing section - four readouts plus an action column, all of which always render, so the row never changes its column count as a weighing progresses. The readouts hold no controls: every button lives in the action column. */}
{/* Current reading, which doubles as the Measure card: both showed the same live weight. Icon and text sit in separate columns (weight-card-icon / -content), matching the reference layout - the icon spans the card's full height instead of sitting inline with the label. */}
{/* Tare. A lone reading is parked here by the backend, but a lighter second one moves it to Gross - the slot is shown plainly rather than hedged behind a neutral label. */}
{/* Gross */}
{/* Net */}
{/* Action column. Print/Design only now - Measure/Manual entry moved into the Current Reading card, since they act on that card's live value. This column is empty (just its panel chrome) until weighing is finalized. */}
{netWeight !== null && ( <> {/* Split button: the main action routes by the saved report's kind; the caret opens an explicit choice for starting the other kind. */}
{showDesignMenu && ( <> {/* Click-away closes the menu. */}
setShowDesignMenu(false)} />
)}
)}
{/* Extra fields from nomenclatures */} {vehicleNomenclatures.length > 0 && (

Additional Data

{visibleForSelected.map(nom => (
handleExtraChange(nom.code, val)} />
))}
)}
)}
{showNoticeReport && vehicleReportData && ( 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?" />
)}
); }