added print, download as pdf. no puppeteer, we'll see

This commit is contained in:
2026-09-22 22:06:06 +03:00
parent 122480cd46
commit 6f614270e7
37 changed files with 3955 additions and 473 deletions
+277 -202
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
@@ -12,6 +12,7 @@ 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';
@@ -27,7 +28,7 @@ export default function Main() {
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: '' });
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);
@@ -99,6 +100,46 @@ export default function Main() {
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
@@ -152,7 +193,7 @@ export default function Main() {
// Cancel new vehicle form
const handleCancelNew = () => {
setShowNewForm(false);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({});
setError('');
};
@@ -168,6 +209,7 @@ export default function Main() {
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);
@@ -178,7 +220,7 @@ export default function Main() {
const res = await api.post('/api/vehicles/', payload);
setSelectedVehicleId(res.data.id);
setShowNewForm(false);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({});
} catch (err) {
const data = err.response?.data;
@@ -285,53 +327,11 @@ export default function Main() {
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(() => {
if (!selectedVehicle) return null;
const rawExtra = selectedVehicle.extra?.data || {};
const extra = {};
for (const [code, value] of Object.entries(rawExtra)) {
const def = definitions[code];
if (!def) continue;
if (def.kind === 'lookup') {
const entry = (entries[code] || []).find(e => e.id === value);
extra[def.name] = entry?.display_value ?? '';
} else {
extra[def.name] = value ?? '';
}
}
return {
vehicle: {
...selectedVehicle,
net: netWeight,
extra,
}
};
}, [selectedVehicle, netWeight, definitions, entries]);
// 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
@@ -351,9 +351,12 @@ export default function Main() {
import('./ReportEditor/a4/print/printReport').catch(() => {});
};
// Print Notice is kind-aware: an A4 report goes straight to the browser
// print dialog (where "Save as PDF" is a destination), a matrix report opens
// the existing on-screen modal exactly as before.
// 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);
@@ -363,14 +366,7 @@ export default function Main() {
setError('Report "notice_report" not found. Create it in the Report Editor first.');
return;
}
if (report.kind === 'a4') {
// Lazy so Main.jsx does not pull the A4 print tree into the
// main bundle for operators who never print an A4 notice.
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
await printA4Report(report, vehicleReportData);
} else {
setShowNoticeReport(true);
}
setShowNoticeReport(true);
} catch (err) {
console.error('Failed to print notice:', err);
setError('Failed to load the notice report');
@@ -487,7 +483,7 @@ export default function Main() {
<h2 className="vehicle-detail-title">New Vehicle</h2>
<div className="vehicle-form-section">
<div className="extra-fields">
<div className="vehicle-new-fields">
<div className="extra-field">
<label>Vehicle Number *</label>
<input
@@ -525,16 +521,25 @@ export default function Main() {
disabled={isSaving}
/>
</div>
<div className="extra-field">
<label>Driver Name *</label>
<input
type="text"
value={newForm.driver_name}
onChange={e => setNewForm(prev => ({ ...prev, driver_name: e.target.value }))}
disabled={isSaving}
/>
</div>
</div>
</div>
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombinationNew}
/>
<h3>Additional Data</h3>
<div className="extra-fields">
{visibleForNew.map(nom => (
<div key={nom.code} className="extra-field">
@@ -561,7 +566,7 @@ export default function Main() {
<button
className="extra-save-btn"
onClick={handleCreateVehicle}
disabled={isSaving || !newForm.vehicle_number.trim()}
disabled={isSaving || !newForm.vehicle_number.trim() || !newForm.driver_name.trim()}
>
{isSaving ? 'Creating...' : 'Create Vehicle'}
</button>
@@ -575,179 +580,249 @@ export default function Main() {
<div className="vehicle-detail">
{error && <div className="vehicle-error">{error}</div>}
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
{/* Current reading */}
<div className="reading-display">
<label>Current Reading</label>
<div className={`reading-value ${
!isConnected
? 'reading-value--disconnected'
: currentWeight === null || isNaN(currentWeight)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{currentWeight !== null && !isNaN(currentWeight)
? `${currentWeight} kg`
: (isConnected ? 'Waiting...' : 'Disconnected')
}
{/* 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. */}
<div className="vehicle-hero">
<div className="vehicle-heading">
<span className="vehicle-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h11v11H3z" />
<path d="M14 10h4l3 3v4h-7z" />
<circle cx="7" cy="19" r="2" />
<circle cx="18" cy="19" r="2" />
</svg>
</span>
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
<span className={`vehicle-status ${isFinalized ? 'vehicle-status--done' : 'vehicle-status--progress'}`}>
● {isFinalized ? 'Finalized' : 'In progress'}
</span>
{isConnected && currentWeight !== null && !isNaN(currentWeight) && (
<span className="vehicle-status vehicle-status--scale">◉ On Scale</span>
)}
</div>
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
{/* 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. */}
<div className="vehicle-hero-equalizer">
<button
type="button"
className="equalizer-scale-toggle"
onClick={() => setEqualizerScaleMode(m => m === 'fixed' ? 'auto' : 'fixed')}
title={equalizerScaleMode === 'fixed'
? 'Scaled to 60,000 kg - click for auto-scale'
: 'Auto-scaled to visible readings - click for fixed 60,000 kg'}
>
{equalizerScaleMode === 'fixed' ? '60t' : 'AUTO'}
</button>
<div className="equalizer-bars">
{equalizerSlots.map((slot, i) => (
<span
key={i}
className={`equalizer-bar ${
slot.balanced === null ? '' : slot.balanced ? 'equalizer-bar--balanced' : 'equalizer-bar--unbalanced'
}`}
style={{ height: `${slot.heightPct}%` }}
/>
))}
</div>
)}
</div>
<div className="vehicle-hero-photo" aria-hidden="true" />
</div>
{/* Weighing section */}
{/* 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. */}
<div className="weighing-section">
{/* 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 ? (
<div className="weight-card">
<div className="weight-card-header">First weighing</div>
<div className="weight-card-value">
{firstWeighing ? `${firstWeighing.value} kg` : '---'}
{/* 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. */}
<div className="weight-card weight-card--reading">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Current Reading</div>
<div className={`weight-card-value weight-card-value--reading ${
!isConnected
? 'reading-value--disconnected'
: currentWeight === null || isNaN(currentWeight)
? ''
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
}`}>
{currentWeight !== null && !isNaN(currentWeight)
? `${currentWeight} kg`
: (isConnected ? 'Waiting...' : 'Disconnected')
}
</div>
{firstWeighing && (
<div className="weight-card-meta">
{formatDate(firstWeighing.date)}
{firstWeighing.userName && ` (${firstWeighing.userName})`}
{firstWeighing.manual && <span className="weight-manual-tag">manual</span>}
{isConnected && readingError && latestReading && (
<div className="reading-error" title={latestReading.data}>
{readingError}
</div>
)}
</div>
) : (
<>
{/* Tare */}
<div className="weight-card">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">{selectedVehicle.tare} kg</div>
{/* Measure/Manual entry, moved here from the action column:
they act on THIS card's live value, so they sit beside it. */}
{canMeasure && !isFinalized && (
<div className="weight-card-reading-actions">
<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>
)}
</div>
{/* 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. */}
<div className="weight-card weight-card--tare">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Tare</div>
<div className="weight-card-value">
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '—'}
</div>
{selectedVehicle.tare != null && (
<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>
</div>
{/* Gross */}
<div className="weight-card">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
{/* Gross */}
<div className="weight-card weight-card--gross">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Gross</div>
<div className="weight-card-value">
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '—'}
</div>
{selectedVehicle.gross != null && (
<div className="weight-card-meta">
{formatDate(selectedVehicle.gross_date)}
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
{selectedVehicle.gross_manual && <span className="weight-manual-tag">manual</span>}
</div>
</div>
</>
)}
{/* Measure */}
{canMeasure && !isFinalized && (
<div className="weight-card">
<div className="weight-card-header">Scale</div>
<div className={`weight-card-value ${
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>
)}
</div>
{/* Net */}
{netWeight !== null && (
<div className="weight-card weight-card--net">
<div className="weight-card weight-card--net">
<span className="weight-card-icon" aria-hidden="true" />
<div className="weight-card-content">
<div className="weight-card-header">Net</div>
<div className="weight-card-value">{netWeight} kg</div>
<button
className="weight-set-btn"
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
{isPrinting ? 'Preparing...' : 'Print Notice'}
</button>
{/* Split button: the main action routes by
the saved report's kind; the caret opens
an explicit choice for starting the other
kind. */}
<div className="design-notice-split">
<button
className="weight-set-btn design-notice-main"
onClick={handleDesignNotice}
>
Design Notice
</button>
<button
className="weight-set-btn design-notice-caret"
onClick={() => setShowDesignMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showDesignMenu}
title="Choose which editor to open"
>
▾
</button>
{showDesignMenu && (
<>
{/* Click-away closes the menu. */}
<div
className="design-notice-backdrop"
onClick={() => setShowDesignMenu(false)}
/>
<div className="design-notice-menu" role="menu">
<button
role="menuitem"
onClick={() => handleDesignAs('matrix')}
>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button
role="menuitem"
onClick={() => handleDesignAs('a4')}
>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
<div className="weight-card-value">
{netWeight !== null ? `${netWeight} kg` : '—'}
</div>
</div>
)}
</div>
{/* 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. */}
<div className="weight-card weight-actions">
{netWeight !== null && (
<>
<button
className="weight-set-btn"
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
{isPrinting ? 'Preparing...' : 'Print Notice'}
</button>
{/* Split button: the main action routes by
the saved report's kind; the caret opens
an explicit choice for starting the other
kind. */}
<div className="design-notice-split">
<button
className="weight-set-btn design-notice-main"
onClick={handleDesignNotice}
>
Design Notice
</button>
<button
className="weight-set-btn design-notice-caret"
onClick={() => setShowDesignMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showDesignMenu}
title="Choose which editor to open"
>
▾
</button>
{showDesignMenu && (
<>
{/* Click-away closes the menu. */}
<div
className="design-notice-backdrop"
onClick={() => setShowDesignMenu(false)}
/>
<div className="design-notice-menu" role="menu">
<button
role="menuitem"
onClick={() => handleDesignAs('matrix')}
>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button
role="menuitem"
onClick={() => handleDesignAs('a4')}
>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
</>
)}
</div>
</div>
{/* Extra fields from nomenclatures */}
{vehicleNomenclatures.length > 0 && (
<div className="extra-section">
<h3>Additional Data</h3>
<ExtraCombinationsTable
nomenclatures={vehicleNomenclatures}
onApply={handleApplyCombination}
/>
<h3>Additional Data</h3>
<div className="extra-fields">
{visibleForSelected.map(nom => (
<div key={nom.code} className="extra-field">