made the long waited animation when add new truck or truck is completed. mouse over the list prevents the list from changing, only the animations work. moved top menu buttons to the right sidebar to free some vertical space. license text on button hover, not fixed above

This commit is contained in:
2026-10-03 16:40:36 +03:00
parent 4b0739ba06
commit 6cf4c74845
29 changed files with 2577 additions and 718 deletions
View File
+105
View File
@@ -0,0 +1,105 @@
from datetime import timedelta
from unittest import mock
from django.utils import timezone
from rest_framework.test import APITestCase
from api.models import User
from vehicles.models import Vehicle
STATION = {'station_key': 'test-station'}
class OneWeightTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user('op', password='x', can_measure=True)
self.other = User.objects.create_user('first', password='x')
self.client.force_authenticate(self.user)
p = mock.patch('api.views._station_allowed', return_value=True)
p.start()
self.addCleanup(p.stop)
p = mock.patch('api.views.sse_broadcast_update')
self.sse = p.start()
self.addCleanup(p.stop)
def url(self, v):
return f'/api/vehicles/{v.pk}/one-weight/'
def make(self, **kw):
return Vehicle.objects.create(vehicle_number='CB1234', driver_name='D', **kw)
def test_moves_tare_to_gross_and_finalizes(self):
when = timezone.now() - timedelta(hours=1)
v = self.make(tare=1500, tare_date=when, tare_user=self.other, tare_manual=True,
tare_manual_user=self.other, tare_manual_date=when, tare_manual_note='n')
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 200, r.content)
v.refresh_from_db()
self.assertEqual(v.gross, 1500)
self.assertEqual(v.gross_date, when)
self.assertEqual(v.gross_user, self.other)
self.assertTrue(v.gross_manual)
self.assertEqual(v.gross_manual_user, self.other)
self.assertEqual(v.gross_manual_date, when)
self.assertEqual(v.gross_manual_note, 'n')
for f in ('tare', 'tare_date', 'tare_user', 'tare_manual_user',
'tare_manual_date', 'tare_manual_note'):
self.assertIsNone(getattr(v, f), f)
self.assertFalse(v.tare_manual)
self.assertEqual(v.net, 1500)
self.assertEqual(v.net_user, self.user)
self.assertIsNotNone(v.net_date)
self.assertTrue(v.doc_number)
self.assertEqual(r.data['doc_number'], v.doc_number)
self.assertEqual(r.data['net'], 1500)
self.assertIsNone(r.data['tare'])
self.sse.assert_called_once()
self.assertEqual(self.sse.call_args.args[:2], ('vehicle', 'updated'))
def test_gross_already_set(self):
when = timezone.now()
v = self.make(gross=9000, gross_date=when, gross_user=self.other)
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 200, r.content)
v.refresh_from_db()
self.assertEqual((v.gross, v.gross_user, v.gross_date), (9000, self.other, when))
self.assertIsNone(v.tare)
self.assertEqual(v.net, 9000)
self.assertTrue(v.doc_number)
def _assert_400(self, v):
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 400)
self.assertIn('detail', r.data)
self.sse.assert_not_called()
def test_already_finalized(self):
self._assert_400(self.make(gross=5, net=5, doc_number='2026-1'))
def test_void(self):
self._assert_400(self.make(tare=5, is_void=True))
def test_no_weight(self):
self._assert_400(self.make())
def test_both_weights(self):
self._assert_400(self.make(tare=5, gross=10))
def test_permission_denied(self):
v = self.make(tare=5)
self.client.force_authenticate(self.other)
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 403)
v.refresh_from_db()
self.assertEqual(v.tare, 5)
self.assertIsNone(v.doc_number)
def test_station_not_licensed(self):
v = self.make(tare=5)
with mock.patch('api.views._station_allowed', return_value=False):
r = self.client.post(self.url(v), STATION, format='json')
self.assertEqual(r.status_code, 403)
def test_unknown_vehicle_404(self):
r = self.client.post('/api/vehicles/999999/one-weight/', STATION, format='json')
self.assertEqual(r.status_code, 404)
+71 -3
View File
@@ -2,6 +2,7 @@ from rest_framework import viewsets, status, mixins
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated, AllowAny
from .models import (ComPortReading, User, Report, ScaleProtocol, License,
StationSlot, generate_doc_number)
@@ -11,6 +12,7 @@ from .serializers import (
NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
ScaleProtocolSerializer,
)
from django.db import transaction
from django.utils import timezone
from vehicles.models import Vehicle, ExtraCombination, combination_hash
from nomenclatures.models import Nomenclature, NomenclatureEntry
@@ -131,6 +133,24 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
serializer.save(source_ip=self.get_client_ip(self.request))
_WEIGHING_SUFFIXES = ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note')
def _move_weighing(vehicle, src_prefix, dst_prefix):
"""Copy a recorded weighing to the other slot, carrying all its provenance.
The source slot is left untouched (callers overwrite or clear it as needed).
"""
for suffix in _WEIGHING_SUFFIXES:
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
def _clear_weighing(vehicle, prefix):
"""Reset every field of a weighing slot to its empty state."""
for suffix in _WEIGHING_SUFFIXES:
setattr(vehicle, f'{prefix}{suffix}', False if suffix == '_manual' else None)
def _maybe_finalize(vehicle, user):
"""If both tare and gross are set and net hasn't been calculated yet, compute net and assign doc_number."""
if vehicle.tare is not None and vehicle.gross is not None and vehicle.net is None:
@@ -159,9 +179,7 @@ def _assign_weighing(vehicle, value, user, manual=False, note=None):
setattr(vehicle, f'{prefix}_manual_note', note if manual else None)
def move(src_prefix, dst_prefix):
"""Move a recorded weighing to the other slot, carrying all its provenance."""
for suffix in ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note'):
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
_move_weighing(vehicle, src_prefix, dst_prefix)
if vehicle.tare is not None and vehicle.gross is not None:
raise ValueError('Vehicle already has both weights.')
@@ -231,6 +249,7 @@ class VehicleViewSet(viewsets.ModelViewSet):
destroy: Disabled - vehicles are never deleted, see void() instead
by_number: Get vehicle by vehicle_number
measure: Record a weighing (app assigns tare vs gross by weight)
one_weight: Finalize a vehicle weighed once (weight becomes gross, net = gross)
void: Flag a completed vehicle as voided (corrective reversal, not a delete)
mark_notice_printed: Signal that a completed vehicle's notice was printed/downloaded
"""
@@ -333,6 +352,55 @@ class VehicleViewSet(viewsets.ModelViewSet):
sse_broadcast_update('vehicle', 'updated', serializer.data)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='one-weight')
def one_weight(self, request, pk=None):
"""Finalize a vehicle weighed only once (external clients).
The single weight is treated as gross: net = gross, tare stays empty.
"""
if not request.user.can_measure:
return Response({'detail': 'You do not have permission to measure.'},
status=status.HTTP_403_FORBIDDEN)
station_key = (request.data.get('station_key') or '').strip()
if not _station_allowed(station_key):
limit = licensing.effective_station_limit()
return Response(
{'detail': f'This station is not licensed to measure '
f'(limit: {limit} stations).'},
status=status.HTTP_403_FORBIDDEN)
with transaction.atomic():
vehicle = get_object_or_404(self.get_queryset().select_for_update(of=('self',)),
pk=pk)
self.check_object_permissions(request, vehicle)
if vehicle.doc_number:
return Response({'detail': 'This vehicle is already finalized.'},
status=status.HTTP_400_BAD_REQUEST)
if vehicle.is_void:
return Response({'detail': 'A voided vehicle cannot be finalized.'},
status=status.HTTP_400_BAD_REQUEST)
if (vehicle.tare is None) == (vehicle.gross is None):
return Response(
{'detail': 'Exactly one weight must be recorded to finalize with one weight.'},
status=status.HTTP_400_BAD_REQUEST)
if vehicle.tare is not None:
_move_weighing(vehicle, 'tare', 'gross')
_clear_weighing(vehicle, 'tare')
now = timezone.now()
vehicle.net = vehicle.gross
vehicle.net_date = now
vehicle.net_user = request.user
vehicle.doc_number = generate_doc_number(now.year)
vehicle.save()
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'updated', serializer.data)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='void')
def void(self, request, pk=None):
"""Flag a completed vehicle as voided. Does not touch doc_number/net/tare/gross."""
+8 -5
View File
@@ -8,6 +8,7 @@ import Main from './components/Main';
import ReportEditor from './components/ReportEditor/ReportEditor';
import UserManager from './components/Users/UserManager';
import History from './components/History';
import RailLayout from './components/RailLayout';
import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
@@ -77,13 +78,15 @@ function AppContent() {
<LicenseProvider>
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
<Routes>
<Route path="/" element={<Main />} />
<Route path="/history" element={<History />} />
<Route element={<RailLayout />}>
<Route path="/" element={<Main />} />
<Route path="/history" element={<History />} />
<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="/" />} />
</Route>
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
<Route path="/report-editor-a4" element={currentUser?.can_edit_documents ? <A4ReportEditor /> : <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>
</Suspense>
</LicenseProvider>
-223
View File
@@ -1,223 +0,0 @@
.app-header {
background: var(--header-bg);
color: var(--header-text);
padding: 0;
box-shadow: var(--shadow-raised);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-4) var(--space-5);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.header-left h1 {
margin: 0;
font-size: var(--fs-2xl);
font-weight: var(--fw-semibold);
}
.nav-button {
width: 42px;
height: 42px;
border-radius: var(--radius);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
font-size: var(--fs-2xl);
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-button:hover {
background: var(--header-chrome-hover);
transform: scale(1.05);
}
/* Header dropdown — the report editor button opens a choice of editors rather
than navigating straight to one, since the two are not interchangeable. */
.nav-dropdown {
position: relative;
display: flex;
}
/* Over the page, under the menu: any outside click closes it. */
.nav-dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 100;
}
.nav-dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 101;
min-width: 220px;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
.nav-dropdown-menu button {
display: block;
width: 100%;
padding: var(--space-2-5) var(--space-4);
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: inherit;
}
.nav-dropdown-menu button + button {
border-top: 1px solid var(--border);
}
.nav-dropdown-menu button:hover {
background: var(--surface-hover);
}
.nav-dropdown-menu strong {
display: block;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
}
.nav-dropdown-menu span {
display: block;
margin-top: var(--space-0-5);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.header-right {
display: flex;
align-items: center;
gap: var(--space-5);
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: var(--space-2-5);
}
.user-name {
font-weight: var(--fw-semibold);
font-size: var(--fs-md);
}
.user-role {
font-size: var(--fs-sm);
opacity: 0.9;
}
.avatar-container {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--header-avatar-bg);
color: var(--header-avatar-text);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--fw-bold);
font-size: var(--fs-lg);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-card);
}
.avatar:hover {
transform: scale(1.05);
box-shadow: var(--shadow-raised);
}
.logout-button {
padding: var(--space-2) var(--space-4);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
border-radius: 6px;
font-size: var(--fs-md);
font-weight: var(--fw-medium);
cursor: pointer;
transition: background 0.2s;
}
.logout-button:hover {
background: var(--header-chrome-hover);
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: var(--space-3);
align-items: stretch;
}
.header-left h1 {
font-size: var(--fs-xl);
text-align: center;
}
.header-right {
justify-content: space-between;
}
.user-info {
align-items: flex-start;
margin-right: 0;
}
}
/* Theme toggle — same chrome as the nav buttons, so it reads as part of the
header furniture rather than a feature being advertised. */
.theme-toggle {
width: 38px;
height: 38px;
border-radius: var(--radius-sm);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
font-size: var(--fs-xl);
line-height: 1;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
}
.theme-toggle:hover {
background: var(--header-chrome-hover);
transform: scale(1.05);
}
-151
View File
@@ -1,151 +0,0 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const navigate = useNavigate();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const [showEditorMenu, setShowEditorMenu] = useState(false);
const openEditor = (route) => {
setShowEditorMenu(false);
navigate(route);
};
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
const handleAvatarClick = () => {
setShowPasswordOverlay(true);
};
return (
<>
<header className="app-header">
<div className="header-content">
<div className="header-left">
{currentUser?.can_edit_documents && (
<div className="nav-dropdown">
<button
className="nav-button"
onClick={() => setShowEditorMenu(v => !v)}
title="Report Editor"
aria-haspopup="menu"
aria-expanded={showEditorMenu}
>
📝
</button>
{showEditorMenu && (
<>
{/* Click-away close, without a document-level listener. */}
<div
className="nav-dropdown-backdrop"
onClick={() => setShowEditorMenu(false)}
/>
<div className="nav-dropdown-menu" role="menu">
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
)}
{currentUser?.can_manage_entities && (
<button
className="nav-button"
onClick={() => setShowNomenclatureManager(true)}
title="Nomenclatures"
>
📋
</button>
)}
<button
className="nav-button"
onClick={() => navigate('/history')}
title="History"
>
🕘
</button>
{currentUser?.is_admin && (
<button
className="nav-button"
onClick={() => navigate('/users')}
title="User Manager"
>
👥
</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>
</div>
<div className="header-right">
<button
className="theme-toggle"
onClick={toggleTheme}
title={theme === 'cockpit' ? 'Switch to the classic theme' : 'Switch to the cockpit theme'}
aria-label="Toggle colour theme"
>
{theme === 'cockpit' ? '☀' : '◐'}
</button>
<div className="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
</div>
<div className="avatar-container">
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
{getInitials(currentUser || {})}
</div>
<button className="logout-button" onClick={logout} title="Logout">
Logout
</button>
</div>
</div>
</div>
</header>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
export default Header;
-2
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import Header from './Header';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import { buildVehicleReportData } from '../services/vehicleReportData';
@@ -124,7 +123,6 @@ export default function History() {
return (
<div>
<Header />
<div className="history">
<h2 className="history-title">History</h2>
@@ -1,5 +1,5 @@
.license-manager {
min-height: 100vh;
flex: 1 0 auto; /* fill the rail layout's content column */
background-color: var(--surface-sunken);
}
@@ -244,23 +244,3 @@
color: var(--text);
font-family: var(--font-mono);
}
/* Persistent, non-dismissible banner shown app-wide during grace */
.license-banner {
padding: var(--space-2) var(--space-4);
background-color: var(--warning-soft);
border-bottom: 1px solid var(--warning);
color: var(--warning);
font-size: var(--fs-base);
text-align: center;
}
.license-banner strong {
font-weight: var(--fw-semibold);
}
.license-banner--blocked {
background-color: var(--danger-soft);
border-bottom-color: var(--danger);
color: var(--danger);
}
@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import { useLicense } from '../../contexts/LicenseContext';
import api from '../../services/api';
import './LicenseManager.css';
@@ -40,7 +39,6 @@ export default function LicenseManager() {
if (!status) {
return (
<div className="license-manager">
<Header />
<div className="lm-body"><div className="lm-loading">Loading licence...</div></div>
</div>
);
@@ -50,7 +48,6 @@ export default function LicenseManager() {
return (
<div className="license-manager">
<Header />
<div className="lm-body">
<div className="lm-toolbar">
<button className="lm-back" onClick={() => navigate('/')}>&larr; Back</button>
+260 -111
View File
@@ -1,7 +1,17 @@
/* Fills the rail layout's content column (see NavRail.css); the licence
banner, when shown, takes its height and .main gets the rest. */
.main-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.main {
display: flex;
flex-direction: row;
height: calc(100vh - 74px);
flex: 1;
min-height: 0;
}
/* Left panel - vehicle list */
@@ -55,6 +65,7 @@
.vehicle-list {
flex: 1;
overflow-y: auto;
padding-top: 7px; /* room for a marker above the first row */
}
.vehicle-list-item {
@@ -94,6 +105,233 @@
font-size: var(--fs-base);
}
/* Staged list changes (hooks/useStagedList, components/VehicleListStaging).
The row durations come from JS as --vl-enter-ms and --vl-exit-ms (set on
.vehicle-list), so they cannot drift from the hook's timers. The fallbacks
only matter if the list is rendered without them.
Insert: a marker line with a shimmer running along it; when the hook moves
it to `distorting` (the --distorting class) it wobbles and bulges into a
lens (SMIL path morph, timed in the JSX). The row opens out of that lens
and reveals its plate. A leaving row stays put, marked, before it
collapses. While the list is held both just keep waiting.
The marker takes no layout space: it is 0 high, and its line, shimmer and
lens are drawn over the border between the two rows, so the rows only move
once the new row actually opens. */
.vehicle-list-marker {
position: relative;
z-index: 2; /* over the neighbouring rows */
height: 0;
margin: 0 var(--space-2);
overflow-x: clip; /* the shimmer runs past both ends */
pointer-events: none;
animation: vehicle-list-marker-in 200ms ease-out;
}
.vehicle-list-marker-line {
position: absolute;
left: 0;
top: -6px;
display: block;
width: 100%;
height: 12px; /* = H in VehicleListStaging.jsx */
overflow: visible;
opacity: .55;
}
.vehicle-list-marker--distorting .vehicle-list-marker-line {
animation: vehicle-list-marker-wake 250ms ease-out forwards;
}
.vehicle-list-marker-line path {
fill: var(--warning-soft); /* only shows once the edges part (the lens) */
stroke: var(--warning);
stroke-width: 2.5px;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
/* A soft bright spot travelling along the calm line; it is absorbed (scaled
away) when the line starts to distort. `scale` is used for that so it does
not fight the travel animation's transform/opacity. */
.vehicle-list-marker::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 28%;
height: 6px;
margin-top: -3px;
background: radial-gradient(closest-side, var(--warning), transparent);
pointer-events: none;
animation: vehicle-list-shimmer 1.5s cubic-bezier(.45, 0, .55, 1) infinite;
}
/* Same first name, so the travel carries on rather than restarting. */
.vehicle-list-marker--distorting::after {
animation:
vehicle-list-shimmer 1.5s cubic-bezier(.45, 0, .55, 1) infinite,
vehicle-list-shimmer-out 200ms ease-in forwards;
}
.vehicle-list-item--leaving {
cursor: default;
box-shadow: inset 3px 0 0 var(--warning);
animation: vehicle-list-leaving-pulse 1.2s ease-in-out infinite;
}
.vehicle-list-item--leaving,
.vehicle-list-item--leaving:hover {
background: var(--warning-soft);
}
/* Opening: the row grows from 0 (the marker took no space) with a small
overshoot, while ::before (the lens) flattens into two orange edges and
fades. It starts as the marker's 12px lens centred on the row's top edge,
which the clip margin lets show while the row is still flat (without
overflow-clip-margin support it is clipped until the row grows). Not
clickable until stable; `isolation` keeps the lens behind the text. */
.vehicle-list-item--entering {
position: relative;
isolation: isolate;
overflow: hidden;
overflow: clip;
overflow-clip-margin: 6px;
cursor: default;
pointer-events: none;
animation: vehicle-list-open var(--vl-enter-ms, 800ms) both;
}
.vehicle-list-item--entering::before {
content: '';
position: absolute;
z-index: -1;
inset: 0 var(--space-2);
border-block: 2.5px solid var(--warning);
border-radius: 50%;
background: var(--warning-soft);
pointer-events: none;
animation: vehicle-list-lens var(--vl-enter-ms, 800ms) both;
}
/* Plate characters (only while entering) rise and sharpen with a stagger set
inline; the weight follows. */
.vehicle-list-char {
display: inline-block;
white-space: pre;
animation: vehicle-list-char-in calc(var(--vl-enter-ms, 800ms) * .3) cubic-bezier(.2, .8, .2, 1) both;
}
.vehicle-list-item--entering .vehicle-list-weight {
animation: vehicle-list-weight-in calc(var(--vl-enter-ms, 800ms) * .3) ease-out
calc(var(--vl-enter-ms, 800ms) * .62) both;
}
.vehicle-list-item--exiting {
overflow: hidden;
pointer-events: none;
animation: vehicle-list-exit var(--vl-exit-ms, 280ms) ease-in forwards;
}
@keyframes vehicle-list-marker-in {
from { transform: scaleX(0); opacity: 0; }
}
@keyframes vehicle-list-marker-wake {
to { opacity: 1; }
}
@keyframes vehicle-list-shimmer {
0% { transform: translateX(-100%); opacity: 0; }
15%, 85% { opacity: 1; }
100% { transform: translateX(358%); opacity: 0; } /* 100% / 28% of the line */
}
@keyframes vehicle-list-shimmer-out {
to { scale: 0; }
}
@keyframes vehicle-list-leaving-pulse {
0%, 100% { opacity: .7; }
50% { opacity: .45; }
}
/* --vl-row-h is the row's natural height, measured in JS before the first
paint. Padding grows over the whole run; the flex centring does not depend
on it, so the text stays put. */
@keyframes vehicle-list-open {
0% {
height: 0; /* the marker takes no space */
padding-top: 0;
padding-bottom: 0;
border-bottom-width: 0;
animation-timing-function: cubic-bezier(.2, .75, .3, 1);
}
42% { height: calc(var(--vl-row-h, 40px) + 5px); animation-timing-function: ease-in-out; }
58% { height: calc(var(--vl-row-h, 40px) - 2px); animation-timing-function: ease-in-out; }
72%, 100% { height: var(--vl-row-h, 40px); }
}
/* Starts 12px high (the marker's lens) around the 0-high row, then fits it. */
@keyframes vehicle-list-lens {
0% {
top: -6px;
bottom: -6px;
border-radius: 50%;
animation-timing-function: cubic-bezier(.2, .75, .3, 1);
}
45% { top: 0; bottom: 0; border-radius: var(--radius-sm); opacity: 1; }
90%, 100% { border-radius: var(--radius-sm); opacity: 0; }
}
@keyframes vehicle-list-char-in {
from { opacity: 0; transform: translateY(.45em); filter: blur(3px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
@keyframes vehicle-list-weight-in {
from { opacity: 0; transform: translateX(var(--space-1-5)); }
}
/* Height animates via max-height; the cap only needs to exceed a row. */
@keyframes vehicle-list-exit {
from { max-height: 64px; opacity: .45; }
to {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
border-bottom-width: 0;
opacity: 0;
transform: translateX(calc(-1 * var(--space-2)));
}
}
/* A static, full-strength line while waiting (the JSX also drops the SMIL
distortion) and no opening: the hook skips the entering/exiting phases, so
a row replacing a marker just fades in quickly. */
@media (prefers-reduced-motion: reduce) {
.vehicle-list-marker,
.vehicle-list-marker-line,
.vehicle-list-marker--distorting .vehicle-list-marker-line {
animation: none;
}
.vehicle-list-marker-line {
opacity: 1;
}
.vehicle-list-marker::after,
.vehicle-list-item--entering::before {
display: none;
}
.vehicle-list-item--entering .vehicle-list-weight,
.vehicle-list-char,
.vehicle-list-item--exiting {
animation: none;
}
.vehicle-list-item:not(.vehicle-list-item--leaving) {
animation: vehicle-list-fade-in 150ms ease-out;
}
}
@keyframes vehicle-list-fade-in {
from { opacity: 0; }
}
/* Right panel */
.main-right {
flex: 1;
@@ -288,8 +526,7 @@
/* The buttons carry a stacking margin for when they sit inside a card; in this
column the flex gap does that job. */
.weight-actions > .weight-set-btn,
.weight-actions > .weight-manual-btn,
.weight-actions > .design-notice-split {
.weight-actions > .weight-manual-btn {
margin-top: 0;
}
@@ -430,87 +667,6 @@
cursor: not-allowed;
}
/* Design Notice split button — main action plus a caret that opens an explicit
editor choice. The two halves read as one control, so they share the gradient
and only a hairline divides them. */
.design-notice-split {
position: relative;
display: flex;
gap: 1px;
margin-top: var(--space-1-5);
}
/* .weight-set-btn is width:100%; inside the split the halves are sized by flex. */
.design-notice-split .weight-set-btn {
width: auto;
margin-top: 0;
}
.design-notice-main {
flex: 1;
border-radius: 6px 0 0 6px;
}
.design-notice-caret {
flex: 0 0 28px;
padding: var(--space-2) 0;
border-radius: 0 6px 6px 0;
}
/* Sits under the menu and over everything else, so a click anywhere outside
closes it without needing a document-level listener. */
.design-notice-backdrop {
position: fixed;
inset: 0;
z-index: 20;
}
.design-notice-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
z-index: 21;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 20px var(--shadow-overlay);
overflow: hidden;
}
.design-notice-menu button {
display: block;
width: 100%;
padding: var(--space-2) var(--space-3);
background: none;
border: none;
text-align: left;
cursor: pointer;
font-size: var(--fs-sm);
color: var(--text);
}
.design-notice-menu button + button {
border-top: 1px solid var(--border);
}
.design-notice-menu button:hover {
background: var(--surface-hover);
}
.design-notice-menu strong {
display: block;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
}
.design-notice-menu span {
display: block;
margin-top: 1px;
font-size: var(--fs-xs);
color: var(--text-muted);
}
/* Extra fields section */
.extra-section {
border-top: 1px solid var(--border);
@@ -638,9 +794,15 @@
/* Responsive */
@media (max-width: 768px) {
/* Stacked panels scroll as one page (the rail layout's content column)
rather than each in a squeezed box. */
.main-page {
flex: 1 0 auto;
}
.main {
flex-direction: column;
height: auto;
flex: none;
}
.main-left {
@@ -975,8 +1137,7 @@
/* Illuminated, not flat-filled: a gradient body, a lit inner top edge and an
outer halo, so the primary action looks switched on. The primary buttons are
the DIRECT children of the action column (Measure, Print Notice); the split
button nested below them is secondary. */
the DIRECT children of the action column (Print Notice, One weight only). */
[data-theme="cockpit"] .weight-actions > .weight-set-btn,
[data-theme="cockpit"] .vehicle-add-btn {
background: linear-gradient(180deg, #ff8a3c 0%, #e8631b 100%);
@@ -1033,6 +1194,16 @@
background: linear-gradient(90deg, var(--accent-soft), transparent);
box-shadow: inset 0 0 22px rgba(53, 169, 221, .10);
}
/* The glow follows the line's shape, wobble and lens included. */
[data-theme="cockpit"] .vehicle-list-marker-line {
filter: drop-shadow(0 0 3px var(--warning));
}
[data-theme="cockpit"] .vehicle-list-item--entering::before {
box-shadow: inset 0 0 14px var(--warning-soft), 0 0 10px var(--warning-soft);
}
[data-theme="cockpit"] .vehicle-list-item--leaving {
background: linear-gradient(90deg, var(--warning-soft), transparent);
}
[data-theme="cockpit"] .vehicle-list-item--active .vehicle-list-number {
color: var(--accent);
text-shadow: 0 0 12px rgba(53, 169, 221, .5);
@@ -1191,9 +1362,8 @@
box-shadow: 0 0 12px rgba(0, 217, 255, .35);
}
/* FIX: Design Notice was rendering orange. It carries .weight-set-btn inside
the action column, so the primary-action rule caught it. Only a DIRECT child
of that column is primary; the split button nested below is secondary. */
/* Only a DIRECT child of the action column is primary; restated late so it
wins over the generic cockpit .weight-set-btn treatment. */
[data-theme="cockpit"] .weight-actions > .weight-set-btn,
[data-theme="cockpit"] .vehicle-add-btn {
background: linear-gradient(180deg, #ff8a3c 0%, #e8541b 100%);
@@ -1202,21 +1372,6 @@
text-shadow: none;
}
/* ...and explicitly hand the split button back its secondary treatment. */
[data-theme="cockpit"] .design-notice-main,
[data-theme="cockpit"] .design-notice-caret {
background: linear-gradient(180deg, rgba(0, 217, 255, .16) 0%, rgba(0, 217, 255, .05) 100%);
border: 1px solid var(--border-strong);
color: var(--accent);
box-shadow: inset 0 1px 0 rgba(0, 217, 255, .18);
text-shadow: 0 0 10px rgba(0, 217, 255, .45);
}
[data-theme="cockpit"] .design-notice-main:hover:not(:disabled),
[data-theme="cockpit"] .design-notice-caret:hover:not(:disabled) {
background: linear-gradient(180deg, rgba(0, 217, 255, .28) 0%, rgba(0, 217, 255, .10) 100%);
border-color: var(--accent);
}
/* The combinations table now uses tokens directly (its stylesheet was
converted), so it needs only its header tinted to the panel voice. */
[data-theme="cockpit"] .extra-combos-table th {
@@ -1259,9 +1414,3 @@
[data-theme="cockpit"] .vehicle-list-item--active .vehicle-list-weight {
color: var(--accent);
}
/* The header needs its own lit underline, or the bar floats. */
[data-theme="cockpit"] .app-header {
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 0 rgba(0, 217, 255, .25), 0 6px 24px rgba(0, 0, 0, .6);
}
+153 -152
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
import useStagedList, { prefersReducedMotion } from '../hooks/useStagedList';
import { useAuth } from '../contexts/AuthContext';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
@@ -11,19 +10,21 @@ import { parseReading } from '../services/scaleProtocols';
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
import ReportPreviewModal from './ReportPreviewModal';
import ExtraCombinationsTable from './ExtraCombinationsTable';
import { LIST_STAGING, listStagingStyle, PendingMarker, RevealPlate, measureOpeningRow, shouldHoldList } from './VehicleListStaging';
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
import { buildVehicleReportData } from '../services/vehicleReportData';
import { buildVehicleReportData, vehicleNetWeight } from '../services/vehicleReportData';
import api from '../services/api';
import './Main.css';
import './License/LicenseManager.css';
const getVehicleKey = v => v.id;
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 { allowed: licenseAllowed, detail: licenseDetail, stationKey } = useLicense();
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
@@ -34,7 +35,7 @@ export default function Main() {
const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [showDesignMenu, setShowDesignMenu] = useState(false);
const [isOneWeighing, setIsOneWeighing] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
const [manualForm, setManualForm] = useState({ value: '', note: '' });
@@ -165,12 +166,60 @@ export default function Main() {
[vehicles, selectedVehicleId]
);
// Filtered vehicles
// While the mouse is over the list, staged changes wait (useStagedList
// `hold`). Only mouse/pen hold, so a touch screen never gets stuck. A
// click on a row releases; a mouse move REHOLD_PX away from the click
// point holds again (shouldHoldList).
const [isListHeld, setIsListHeld] = useState(false);
const lastPointerRef = useRef(null);
const holdReleasedAtRef = useRef(null);
const trackListPointer = e => {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') {
lastPointerRef.current = { x: e.clientX, y: e.clientY };
}
};
const handleListPointerOver = e => {
trackListPointer(e);
if (!shouldHoldList(e, holdReleasedAtRef.current)) return;
holdReleasedAtRef.current = null;
if (!isListHeld) setIsListHeld(true);
};
const handleListPointerLeave = () => {
holdReleasedAtRef.current = null;
setIsListHeld(false);
};
const releaseListHoldOnClick = () => {
holdReleasedAtRef.current = lastPointerRef.current;
setIsListHeld(false);
};
useEffect(() => {
const release = () => setIsListHeld(false);
const handleVisibilityChange = () => { if (document.hidden) release(); };
window.addEventListener('blur', release);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('blur', release);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// The list lags the live data so rows never shift under the cursor
// unannounced; the search filter applies to it instantly.
const stagedVehicles = useStagedList(vehicles, {
getKey: getVehicleKey,
delay: LIST_STAGING.delay,
distortMs: LIST_STAGING.distortMs,
enterMs: LIST_STAGING.enterMs,
animationMs: LIST_STAGING.exitMs,
hold: isListHeld,
releaseStagger: LIST_STAGING.releaseStagger,
});
const filteredVehicles = useMemo(() => {
if (!searchQuery.trim()) return vehicles;
if (!searchQuery.trim()) return stagedVehicles;
const q = searchQuery.toLowerCase();
return vehicles.filter(v => v.vehicle_number.toLowerCase().includes(q));
}, [vehicles, searchQuery]);
return stagedVehicles.filter(({ item }) => item.vehicle_number.toLowerCase().includes(q));
}, [stagedVehicles, searchQuery]);
const reduceMotion = prefersReducedMotion();
// When vehicle selection changes, load its extra data
useEffect(() => {
@@ -185,7 +234,7 @@ export default function Main() {
const handleNewVehicle = () => {
setSelectedVehicleId(null);
setShowNewForm(true);
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
setNewExtraData({});
setError('');
};
@@ -245,6 +294,24 @@ export default function Main() {
}
};
// Finalize on the single recorded weight (external clients weighed once).
// Like handleMeasure, the screen updates from the server's SSE broadcast.
const handleOneWeight = async () => {
if (!selectedVehicle || isOneWeighing) return;
if (!window.confirm('Finalize with a single weight?')) return;
setIsOneWeighing(true);
setError('');
try {
await api.post(`/api/vehicles/${selectedVehicle.id}/one-weight/`, {
station_key: stationKey,
});
} catch (err) {
setError(err.response?.data?.detail || 'Failed to finalize with a single weight');
} finally {
setIsOneWeighing(false);
}
};
// Record a weighing entered by hand - requires a note
const handleManualMeasure = async () => {
if (!selectedVehicle) return;
@@ -319,13 +386,18 @@ export default function Main() {
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;
// Net weight - the server's net when set (a one-weight vehicle has no tare,
// so gross - tare cannot produce it), otherwise derived from both weighings.
const netWeight = vehicleNetWeight(selectedVehicle);
const canMeasure = !!currentUser?.can_measure;
const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
// doc_number is assigned on finalization by both the second weighing and
// the one-weight path, so it is the one signal that covers both.
const isFinalized = selectedVehicle?.doc_number != null;
// Weighed exactly once and still open - may be closed on that single weight.
const canOneWeight = canMeasure && !isFinalized && !selectedVehicle?.is_void
&& (selectedVehicle?.tare != null) !== (selectedVehicle?.gross != null);
// Vehicle data shaped for report preview/design (shared with History)
const vehicleReportData = useMemo(
@@ -375,67 +447,8 @@ export default function Main() {
}
};
// 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 (
<div>
<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-page">
<div className="main">
{/* Left panel - Vehicle list */}
<div className="main-left">
@@ -455,20 +468,46 @@ export default function Main() {
/>
</div>
<div className="vehicle-list">
{filteredVehicles.map(v => (
<div
key={v.id}
className={`vehicle-list-item ${selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''}`}
onClick={() => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); }}
>
<span className="vehicle-list-number">{v.vehicle_number}</span>
{v.tare != null && (
<span className="vehicle-list-weight">{v.tare} kg</span>
)}
</div>
))}
{filteredVehicles.length === 0 && (
<div
className="vehicle-list"
style={listStagingStyle}
onPointerEnter={handleListPointerOver}
onPointerDown={trackListPointer}
onPointerMove={handleListPointerOver}
onPointerLeave={handleListPointerLeave}
>
{filteredVehicles.map(({ id, item: v, phase }) => {
if (phase === 'pending' || phase === 'distorting') {
return <PendingMarker key={id} reduceMotion={reduceMotion} distorting={phase === 'distorting'} />;
}
const isGone = phase === 'leaving' || phase === 'exiting';
const isEntering = phase === 'entering';
// Only settled rows are clickable: an opening row is
// still changing size under the cursor.
const isInteractive = phase === 'stable';
const stateClass = {
entering: 'vehicle-list-item--entering',
leaving: 'vehicle-list-item--leaving',
exiting: 'vehicle-list-item--leaving vehicle-list-item--exiting',
}[phase] || '';
return (
<div
key={id}
className={`vehicle-list-item ${!isGone && selectedVehicleId === v.id ? 'vehicle-list-item--active' : ''} ${stateClass}`}
ref={isEntering ? measureOpeningRow : undefined}
aria-disabled={!isInteractive || undefined}
onClick={isInteractive ? () => { setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); releaseListHoldOnClick(); } : undefined}
>
{isEntering && !reduceMotion
? <RevealPlate text={v.vehicle_number} />
: <span className="vehicle-list-number">{v.vehicle_number}</span>}
{v.tare != null && (
<span className="vehicle-list-weight">{v.tare} kg</span>
)}
</div>
);
})}
{!filteredVehicles.some(e => e.phase !== 'pending' && e.phase !== 'distorting') && (
<div className="vehicle-list-empty">No vehicles found</div>
)}
</div>
@@ -748,69 +787,31 @@ export default function Main() {
</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. */}
{/* Action column. Measure/Manual entry live in the Current
Reading card, since they act on that card's live value. Here:
One weight only after a single weighing, Print Notice once
finalized; otherwise empty (just its panel chrome). */}
<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>
</>
{canOneWeight && (
<button
className="weight-set-btn"
onClick={handleOneWeight}
disabled={isOneWeighing || !licenseAllowed}
title={licenseAllowed ? undefined : licenseDetail}
>
{isOneWeighing ? 'Finalizing...' : 'One weight only'}
</button>
)}
{isFinalized && netWeight !== null && (
<button
className="weight-set-btn"
onClick={handlePrintNotice}
onMouseEnter={prefetchA4Print}
onFocus={prefetchA4Print}
disabled={isPrinting}
>
{isPrinting ? 'Preparing...' : 'Print Notice'}
</button>
)}
</div>
</div>
+428
View File
@@ -0,0 +1,428 @@
/* App shell: the page content takes everything left of the rail and exactly
the viewport height. The content column scrolls on its own, so no screen can
produce a page (body) scrollbar; a screen that must fit without scrolling
(Main) sizes itself to this column with flex instead of calc(100vh - x). */
.app-shell {
display: flex;
height: 100vh;
height: 100dvh;
overflow: hidden;
}
.app-shell-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
/* The rail is the old header turned on its side, so it keeps the header's
surface tokens. */
.nav-rail {
--rail-width: 68px;
position: relative;
z-index: 100;
width: var(--rail-width);
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) 0;
background: var(--header-bg);
color: var(--header-text);
box-shadow: var(--shadow-raised);
}
.nav-rail-top,
.nav-rail-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
}
.nav-rail-bottom {
gap: var(--space-2-5);
}
/* Positioning context for a flyout opening beside its trigger. */
.nav-rail-anchor {
position: relative;
display: flex;
}
.nav-rail-item {
width: 60px;
padding: var(--space-1-5) var(--space-0-5) var(--space-1);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-0-5);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--header-text);
font-family: inherit;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.nav-rail-item:hover {
background: var(--header-chrome);
border-color: var(--header-chrome-border);
}
.nav-rail-icon {
font-size: var(--fs-xl);
line-height: 1.2;
}
.nav-rail-label {
max-width: 100%;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The lit edge faces the content, the way a tab points at its page. */
.nav-rail-item--active,
.nav-rail-item--active:hover {
background: var(--header-chrome-hover);
border-color: var(--header-chrome-border);
box-shadow: inset 3px 0 0 var(--header-active);
}
.nav-rail-item:focus-visible,
.nav-rail-theme:focus-visible,
.nav-rail-avatar:focus-visible {
outline: 2px solid var(--header-active);
outline-offset: 2px;
}
.nav-rail-theme {
width: 38px;
height: 38px;
border-radius: var(--radius-sm);
background: var(--header-chrome);
border: 1px solid var(--header-chrome-border);
color: var(--header-text);
font-size: var(--fs-xl);
line-height: 1;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-rail-theme:hover {
background: var(--header-chrome-hover);
transform: scale(1.05);
}
.nav-rail-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
padding: 0;
background: var(--header-avatar-bg);
color: var(--header-avatar-text);
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
font-weight: var(--fw-bold);
font-size: var(--fs-lg);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: var(--shadow-card);
}
.nav-rail-avatar:hover {
transform: scale(1.05);
box-shadow: var(--shadow-raised);
}
/* Over the page, under the flyout: any outside click closes it. */
.nav-rail-backdrop {
position: fixed;
inset: 0;
z-index: 1;
}
/* Flyouts open to the LEFT of the rail, over the page content. */
.nav-rail-flyout {
position: absolute;
right: calc(100% + var(--space-2-5));
z-index: 2;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
.nav-rail-menu {
top: 0;
min-width: 220px;
}
/* The avatar sits at the foot of the rail, so its menu grows upwards. */
.nav-rail-user-menu {
bottom: 0;
min-width: 180px;
}
.nav-rail-flyout button {
display: block;
width: 100%;
padding: var(--space-2-5) var(--space-4);
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: inherit;
font-size: var(--fs-md);
}
.nav-rail-flyout button + button,
.nav-rail-user + button {
border-top: 1px solid var(--border);
}
.nav-rail-flyout button:hover,
.nav-rail-flyout button:focus-visible {
background: var(--surface-hover);
outline: none;
}
.nav-rail-menu strong,
.nav-rail-user strong {
display: block;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
}
.nav-rail-menu span,
.nav-rail-user span {
display: block;
margin-top: var(--space-0-5);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.nav-rail-user {
padding: var(--space-2-5) var(--space-4);
color: var(--text);
background: var(--surface-sunken);
}
/* Licence warning: amber while in grace, red once measuring is blocked. The
colour drives the label, the "!" marker and the badge; an emoji icon keeps
its own colours, so the item also gets a tinted surface. */
.nav-rail-item--license-grace {
--license-tone: var(--warning);
--license-tone-soft: var(--warning-soft);
}
.nav-rail-item--license-blocked {
--license-tone: var(--danger);
--license-tone-soft: var(--danger-soft);
}
.nav-rail-item--license {
color: var(--license-tone);
background: var(--license-tone-soft);
}
.nav-rail-item--license:hover {
background: var(--license-tone-soft);
border-color: var(--license-tone);
}
.nav-rail-item--license .nav-rail-icon {
position: relative;
font-weight: var(--fw-bold);
}
.nav-rail-badge {
position: absolute;
top: 0;
right: -6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--license-tone);
box-shadow: 0 0 0 2px var(--header-bg);
}
/* One gentle pulse when the badge first appears, never on a loop. */
@media (prefers-reduced-motion: no-preference) {
.nav-rail-badge {
animation: nav-rail-badge-pulse 1.2s ease-out 1;
}
}
@keyframes nav-rail-badge-pulse {
0% { transform: scale(1); }
35% { transform: scale(1.6); }
100% { transform: scale(1); }
}
.nav-rail-license-popover {
top: 0;
width: 260px;
border-left: 3px solid var(--warning);
}
.nav-rail-license-popover--blocked {
border-left-color: var(--danger);
}
/* The non-admin marker sits near the foot of the rail: grow upwards. */
.nav-rail-license-popover--bottom {
top: auto;
bottom: 0;
}
.nav-rail-license-popover p {
margin: 0;
padding: var(--space-2-5) var(--space-4);
color: var(--text);
font-size: var(--fs-md);
line-height: 1.4;
}
.nav-rail-license-popover strong {
font-weight: var(--fw-semibold);
}
.nav-rail-license-popover button {
border-top: 1px solid var(--border);
font-weight: var(--fw-semibold);
}
/* Cockpit: the rail needs its own lit edge facing the content, or it floats,
and the active item glows like the rest of the lit instruments. */
[data-theme="cockpit"] .nav-rail {
border-left: 1px solid var(--border);
box-shadow: -1px 0 0 color-mix(in srgb, var(--accent) 25%, transparent),
var(--shadow-raised);
}
[data-theme="cockpit"] .nav-rail-item--active {
color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent), var(--glow-accent);
}
[data-theme="cockpit"] .nav-rail-flyout {
border-color: var(--border-strong);
}
[data-theme="cockpit"] .nav-rail-license-popover {
border-left-color: var(--warning);
}
[data-theme="cockpit"] .nav-rail-license-popover--blocked {
border-left-color: var(--danger);
}
[data-theme="cockpit"] .nav-rail-item--license-grace .nav-rail-badge {
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-warning);
}
[data-theme="cockpit"] .nav-rail-item--license-blocked .nav-rail-badge {
box-shadow: 0 0 0 2px var(--header-bg), var(--glow-danger);
}
/* Short windows: drop the labels before the rail runs out of height. */
@media (max-height: 620px) and (min-width: 700px) {
.nav-rail-label {
display: none;
}
.nav-rail-item {
padding: var(--space-1-5) var(--space-0-5);
}
}
/* Narrow screens: the rail becomes a bottom bar, icons only. */
@media (max-width: 699px) {
.app-shell {
flex-direction: column;
}
.nav-rail {
width: auto;
flex-direction: row;
align-items: center;
padding: var(--space-1) var(--space-2);
}
[data-theme="cockpit"] .nav-rail {
border-left: none;
border-top: 1px solid var(--border);
}
.nav-rail-top,
.nav-rail-bottom {
flex-direction: row;
gap: var(--space-0-5);
}
.nav-rail-top {
flex: 1;
min-width: 0;
justify-content: space-around;
}
.nav-rail-item {
width: auto;
min-width: 36px;
padding: var(--space-1);
}
.nav-rail-label {
display: none;
}
.nav-rail-item--active,
.nav-rail-item--active:hover,
[data-theme="cockpit"] .nav-rail-item--active {
box-shadow: inset 0 -3px 0 var(--header-active);
}
.nav-rail-theme,
.nav-rail-avatar {
width: 34px;
height: 34px;
font-size: var(--fs-md);
}
/* Above the bar instead of beside it. */
.nav-rail-flyout {
right: auto;
left: 0;
top: auto;
bottom: calc(100% + var(--space-2));
}
.nav-rail-user-menu,
.nav-rail-license-popover {
left: auto;
right: 0;
}
.nav-rail-license-popover {
max-width: calc(100vw - 2 * var(--space-4));
}
}
+355
View File
@@ -0,0 +1,355 @@
import React, { useState, useEffect, useRef, useId } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { useLicense } from '../contexts/LicenseContext';
import { getLicenseWarning } from './licenseWarning';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import NomenclatureManager from './NomenclatureManager/NomenclatureManager';
import './NavRail.css';
function RailItem({ icon, label, title, onClick, isActive = false, buttonRef, className = '', noTooltip = false, ...rest }) {
return (
<button
ref={buttonRef}
className={`nav-rail-item${isActive ? ' nav-rail-item--active' : ''}${className ? ` ${className}` : ''}`}
onClick={onClick}
title={noTooltip ? undefined : title}
aria-label={title}
aria-current={isActive ? 'page' : undefined}
{...rest}
>
<span className="nav-rail-icon" aria-hidden="true">{icon}</span>
<span className="nav-rail-label" aria-hidden="true">{label}</span>
</button>
);
}
const LICENSE_OPEN_DELAY_MS = 200;
const LICENSE_CLOSE_DELAY_MS = 250;
// Licence item in its warning state, with a popover beside the rail that opens
// on hover (after a short delay), focus or click. Admins get the real Licence
// item, which still navigates; everyone else gets a "!" marker that does not.
function LicenseWarning({ warning, isAdmin, isActive, onOpenPage }) {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef(null);
const triggerRef = useRef(null);
const timerRef = useRef(null);
const skipFocusOpenRef = useRef(false);
const popoverId = useId();
useEffect(() => () => clearTimeout(timerRef.current), []);
useEffect(() => {
if (!isOpen) return undefined;
const handleMouseDown = (e) => {
if (!anchorRef.current?.contains(e.target)) setIsOpen(false);
};
const handleKeyDown = (e) => {
if (e.key !== 'Escape') return;
clearTimeout(timerRef.current);
setIsOpen(false);
// Only pull focus back when it was inside the popover itself.
const active = document.activeElement;
if (active !== triggerRef.current && anchorRef.current?.contains(active)) {
skipFocusOpenRef.current = true;
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const openNow = () => {
clearTimeout(timerRef.current);
setIsOpen(true);
};
// Entering again (e.g. trigger -> popover across the gap) cancels a pending close.
const handleMouseEnter = () => {
clearTimeout(timerRef.current);
if (!isOpen) timerRef.current = setTimeout(() => setIsOpen(true), LICENSE_OPEN_DELAY_MS);
};
const handleMouseLeave = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIsOpen(false), LICENSE_CLOSE_DELAY_MS);
};
const handleFocus = () => {
if (skipFocusOpenRef.current) {
skipFocusOpenRef.current = false;
return;
}
openNow();
};
// A null relatedTarget is a click on something unfocusable; the outside
// mousedown handler covers the case where that was outside the anchor.
const handleBlur = (e) => {
if (e.relatedTarget && !anchorRef.current?.contains(e.relatedTarget)) {
clearTimeout(timerRef.current);
setIsOpen(false);
}
};
const handleOpenPage = () => {
setIsOpen(false);
onOpenPage();
};
const badgeIcon = (icon) => (
<>
{icon}
<span className="nav-rail-badge" />
</>
);
return (
<div
ref={anchorRef}
className="nav-rail-anchor"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onBlur={handleBlur}
>
<RailItem
buttonRef={triggerRef}
className={`nav-rail-item--license nav-rail-item--license-${warning.level}`}
icon={badgeIcon(isAdmin ? '🔑' : '!')}
label="Licence"
title={`Licence: ${warning.message}`}
noTooltip /* the popover already shows the message; aria-label keeps it for screen readers */
onClick={isAdmin ? onOpenPage : openNow}
onFocus={handleFocus}
isActive={isActive}
aria-describedby={isOpen ? popoverId : undefined}
aria-expanded={isOpen}
aria-controls={isOpen ? popoverId : undefined}
/>
{isOpen && (
<div
id={popoverId}
className={`nav-rail-flyout nav-rail-license-popover nav-rail-license-popover--${warning.level}${isAdmin ? '' : ' nav-rail-license-popover--bottom'}`}
role={isAdmin ? 'dialog' : 'tooltip'}
aria-label={isAdmin ? 'Licence warning' : undefined}
>
<p>
{warning.lead}
{warning.emphasis && <strong>{warning.emphasis}</strong>}
{warning.tail}
</p>
{isAdmin && (
<button onClick={handleOpenPage}>Open licence page</button>
)}
</div>
)}
</div>
);
}
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return (user.username || '').substring(0, 2).toUpperCase();
};
function NavRail() {
const { currentUser, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { status: licenseStatus } = useLicense();
const licenseWarning = getLicenseWarning(licenseStatus);
const navigate = useNavigate();
const { pathname } = useLocation();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const [showNomenclatureManager, setShowNomenclatureManager] = useState(false);
const [showEditorMenu, setShowEditorMenu] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const editorButtonRef = useRef(null);
const avatarButtonRef = useRef(null);
// Escape closes whichever flyout is open and hands focus back to its trigger.
useEffect(() => {
if (!showEditorMenu && !showUserMenu) return undefined;
const handleKeyDown = (e) => {
if (e.key !== 'Escape') return;
const trigger = showEditorMenu ? editorButtonRef.current : avatarButtonRef.current;
setShowEditorMenu(false);
setShowUserMenu(false);
trigger?.focus();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showEditorMenu, showUserMenu]);
const openEditor = (route) => {
setShowEditorMenu(false);
navigate(route);
};
const handleChangePassword = () => {
setShowUserMenu(false);
setShowPasswordOverlay(true);
};
const handleLogout = () => {
setShowUserMenu(false);
logout();
};
return (
<>
<nav className="nav-rail" aria-label="Main navigation">
<div className="nav-rail-top">
<RailItem
icon="⚖️"
label="Weighing"
title="Weighing"
onClick={() => navigate('/')}
isActive={pathname === '/'}
/>
{currentUser?.can_edit_documents && (
<div className="nav-rail-anchor">
<RailItem
buttonRef={editorButtonRef}
icon="📝"
label="Reports"
title="Report Editor"
onClick={() => setShowEditorMenu(v => !v)}
aria-haspopup="menu"
aria-expanded={showEditorMenu}
/>
{showEditorMenu && (
<>
{/* Click-away close, without a document-level listener. */}
<div
className="nav-rail-backdrop"
onClick={() => setShowEditorMenu(false)}
/>
<div className="nav-rail-flyout nav-rail-menu" role="menu">
<button role="menuitem" onClick={() => openEditor('/report-editor')}>
<strong>Matrix editor</strong>
<span>Character grid · dot-matrix printer</span>
</button>
<button role="menuitem" onClick={() => openEditor('/report-editor-a4')}>
<strong>A4 editor</strong>
<span>Millimetres · laser printer &amp; PDF</span>
</button>
</div>
</>
)}
</div>
)}
{currentUser?.can_manage_entities && (
<RailItem
icon="📋"
label="Lists"
title="Nomenclatures"
onClick={() => setShowNomenclatureManager(true)}
/>
)}
<RailItem
icon="🕘"
label="History"
title="History"
onClick={() => navigate('/history')}
isActive={pathname === '/history'}
/>
{currentUser?.is_admin && (
<>
<RailItem
icon="👥"
label="Users"
title="User Manager"
onClick={() => navigate('/users')}
isActive={pathname === '/users'}
/>
<RailItem
icon="📡"
label="Scale"
title="Scale Monitor"
onClick={() => navigate('/scale-monitor')}
isActive={pathname === '/scale-monitor'}
/>
{licenseWarning ? (
<LicenseWarning
warning={licenseWarning}
isAdmin
isActive={pathname === '/license'}
onOpenPage={() => navigate('/license')}
/>
) : (
<RailItem
icon="🔑"
label="Licence"
title="Licence"
onClick={() => navigate('/license')}
isActive={pathname === '/license'}
/>
)}
</>
)}
</div>
<div className="nav-rail-bottom">
{licenseWarning && !currentUser?.is_admin && (
<LicenseWarning warning={licenseWarning} isAdmin={false} />
)}
<button
className="nav-rail-theme"
onClick={toggleTheme}
title={theme === 'cockpit' ? 'Switch to the classic theme' : 'Switch to the cockpit theme'}
aria-label="Toggle colour theme"
>
{theme === 'cockpit' ? '☀' : '◐'}
</button>
<div className="nav-rail-anchor">
<button
ref={avatarButtonRef}
className="nav-rail-avatar"
onClick={() => setShowUserMenu(v => !v)}
title={currentUser?.username}
aria-label={`Account: ${currentUser?.username ?? ''}`}
aria-haspopup="menu"
aria-expanded={showUserMenu}
>
{getInitials(currentUser || {})}
</button>
{showUserMenu && (
<>
<div
className="nav-rail-backdrop"
onClick={() => setShowUserMenu(false)}
/>
<div className="nav-rail-flyout nav-rail-user-menu" role="menu">
<div className="nav-rail-user">
<strong>{currentUser?.username}</strong>
<span>{currentUser?.role}</span>
</div>
<button role="menuitem" onClick={handleChangePassword}>Change password</button>
<button role="menuitem" onClick={handleLogout}>Logout</button>
</div>
</>
)}
</div>
</div>
</nav>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
{showNomenclatureManager && (
<NomenclatureManager onClose={() => setShowNomenclatureManager(false)} />
)}
</>
);
}
export default NavRail;
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import NavRail from './NavRail';
// Shell for every screen that carries the navigation rail. Mounted once as a
// layout route, so the rail does not remount (and lose an open menu) when the
// user moves between the pages inside it.
function RailLayout() {
return (
<div className="app-shell">
<main className="app-shell-content">
<Outlet />
</main>
<NavRail />
</div>
);
}
export default RailLayout;
@@ -83,14 +83,17 @@ const click = (el) =>
act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
// ---------------------------------------------------------------------------
// Main.jsx's Print / Design Notice dispatch
// notice_report lookup + route-by-kind dispatch
// ---------------------------------------------------------------------------
/**
* The branch under test, mirroring Main.jsx's handlePrintNotice /
* handleDesignNotice. Kept as one function so a change to the real dispatch
* that is not made here shows up as an obviously stale test rather than a
* passing one.
* The branch under test: the notice_report lookup (Main.jsx's
* fetchNoticeReport, used by handlePrintNotice) and the route-by-kind rule
* the preview modal's design action (ReportPreviewModal's goDesign) applies.
* Main.jsx no longer has a Design Notice button; the editors stay reachable
* from the header and the preview modal. Kept as one function so a change to
* the real dispatch that is not made here shows up as an obviously stale test
* rather than a passing one.
*/
async function noticeDispatch({ onModal, onA4Print, onNavigate }) {
const res = await api.get('/api/reports/?name=notice_report');
@@ -106,7 +109,7 @@ async function noticeDispatch({ onModal, onA4Print, onNavigate }) {
return { found: true, kind: report.kind };
}
describe('Print / Design Notice dispatch', () => {
describe('notice_report dispatch', () => {
test('a matrix notice_report opens the existing modal, not the A4 print path', async () => {
api.get.mockResolvedValueOnce({
data: [{ id: 1, name: 'notice_report', kind: 'matrix', elements: [] }]
@@ -120,7 +123,7 @@ describe('Print / Design Notice dispatch', () => {
expect(out).toEqual({ found: true, kind: 'matrix' });
expect(onModal).toHaveBeenCalledTimes(1);
expect(onA4Print).not.toHaveBeenCalled();
// Design Notice must route to the MATRIX editor for a matrix report.
// Designing must route to the MATRIX editor for a matrix report.
expect(onNavigate).toHaveBeenCalledWith('/report-editor');
});
@@ -1,5 +1,5 @@
.scale-monitor {
min-height: 100vh;
flex: 1 0 auto; /* fill the rail layout's content column */
background-color: var(--bg);
}
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../Header';
import useSerialData from '../../hooks/useSerialData';
import { useScaleProtocols } from '../../contexts/ScaleProtocolContext';
import { parseReading, formatHex } from '../../services/scaleProtocols';
@@ -120,7 +119,6 @@ export default function ScaleMonitor() {
return (
<div className="scale-monitor">
<Header />
<div className="sm-body">
<div className="sm-toolbar">
<button className="sm-back" onClick={() => navigate('/')}>&larr; Back</button>
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import api from '../../services/api';
import Header from '../Header';
import './UserManager.css';
const PERMISSION_FIELDS = [
@@ -87,7 +86,6 @@ export default function UserManager() {
return (
<div>
<Header />
<div className="um-page">
<h2 className="vehicle-detail-title">User Manager</h2>
@@ -0,0 +1,158 @@
import { useLayoutEffect, useRef } from 'react';
/**
* Visuals for the staged vehicle list (hooks/useStagedList), see Main.css.
*
* A new row is announced by a marker line (pending, `delay` ms): calm with a
* travelling shimmer, then for the last `distortMs` (the hook's `distorting`
* phase) it wobbles like a plucked string and bulges open into a lens. The
* row then opens out of that lens (entering, `enterMs`) and its plate is
* revealed character by character. While the list is held the marker stays
* calm, so the distortion starts when the hook says so, not at a fixed time.
*
* The timings are handed to the CSS as custom properties (listStagingStyle),
* so the hook's timers and the animations come from the same numbers.
*/
export const LIST_STAGING = {
delay: 3000, // marker shown before the row opens
distortMs: 650, // the tail of `delay` in which the marker distorts
enterMs: 800, // row opens, plate and weight are revealed
exitMs: 280, // a leaving row collapses
releaseStagger: 70, // between held changes going on, top to bottom
};
/* ----- Hover hold ----------------------------------------------------------
While a mouse/pen is over the list, staged changes wait. A click on a row
releases the hold at the click point; it only holds again once the pointer
has moved REHOLD_PX away from there, so the jitter of a click does not
re-hold at once. Touch never holds. */
export const REHOLD_PX = 4;
// Whether a pointer event over the list should (re)take the hold, given the
// point a click released it at (null when not in the post-click state).
export function shouldHoldList({ pointerType, clientX, clientY }, releasedAt) {
if (pointerType !== 'mouse' && pointerType !== 'pen') return false;
if (!releasedAt) return true;
return Math.hypot(clientX - releasedAt.x, clientY - releasedAt.y) >= REHOLD_PX;
}
export const listStagingStyle = {
'--vl-enter-ms': `${LIST_STAGING.enterMs}ms`,
'--vl-exit-ms': `${LIST_STAGING.exitMs}ms`,
};
/* ----- Marker line ---------------------------------------------------------
One closed path, top edge left->right then bottom edge back. While both
edges coincide it reads as a single line (zero area, so the fill is
invisible); the last frame pulls them apart into a filled lens, which the
entering row's overlay picks up. Every frame has the same commands, so SMIL
can interpolate `d` (CSS `d: path()` does not animate in Firefox). The
viewBox is stretched to the marker's width; y units are pixels. */
const W = 100;
const H = 12; // = .vehicle-list-marker-line height
const MID = H / 2;
const SEGS = 8;
const fmt = n => Math.round(n * 100) / 100;
// Cubic Bezier segments sampling f between x0 and x1 (Hermite tangents).
function edge(f, x0, x1) {
const h = (x1 - x0) / SEGS;
const slope = x => (f(x + 0.01) - f(x - 0.01)) / 0.02;
let d = '';
for (let i = 0; i < SEGS; i++) {
const a = x0 + i * h, b = a + h;
d += ` C${fmt(a + h / 3)} ${fmt(f(a) + slope(a) * h / 3)}`
+ ` ${fmt(b - h / 3)} ${fmt(f(b) - slope(b) * h / 3)}`
+ ` ${fmt(b)} ${fmt(f(b))}`;
}
return d;
}
const shape = (top, bottom) => `M0 ${fmt(top(0))}${edge(top, 0, W)}${edge(bottom, W, 0)}Z`;
// A string pinned at both ends: a standing wave (modes 3 + 2) and a bulge.
const wave = a => x => MID + a * (0.8 * Math.sin(3 * Math.PI * x / W) + 0.3 * Math.sin(2 * Math.PI * x / W));
const bulge = a => x => a * Math.sin(Math.PI * x / W);
const line = a => shape(wave(a), wave(a));
const lens = (open, a) => shape(x => wave(a)(x) - bulge(open)(x), x => wave(a)(x) + bulge(open)(x));
const FLAT = line(0);
const DISTORT_FRAMES = [FLAT, line(0.7), line(-1.3), line(1.9), line(-2.4), lens(2.4, -0.9), lens(4, 0)];
const DISTORT_TIMES = [0, 0.16, 0.32, 0.48, 0.64, 0.82, 1];
const DISTORT_SPLINES = DISTORT_FRAMES.slice(1).map(() => '.45 0 .55 1').join(';');
// The morph waits (begin="indefinite") until `distorting` turns on, then is
// started with beginElement(); the CSS wake and shimmer-out key off the
// --distorting class at the same moment.
export function PendingMarker({ reduceMotion, distorting = false }) {
const animateRef = useRef(null);
useLayoutEffect(() => {
if (distorting) animateRef.current?.beginElement?.();
}, [distorting]);
return (
<div className={`vehicle-list-marker${distorting ? ' vehicle-list-marker--distorting' : ''}`} aria-hidden="true">
<svg
className="vehicle-list-marker-line"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
>
<path d={FLAT}>
{!reduceMotion && (
<animate
ref={animateRef}
attributeName="d"
begin="indefinite"
dur={`${LIST_STAGING.distortMs}ms`}
fill="freeze"
calcMode="spline"
values={DISTORT_FRAMES.join(';')}
keyTimes={DISTORT_TIMES.join(';')}
keySplines={DISTORT_SPLINES}
/>
)}
</path>
</svg>
</div>
);
}
/* ----- Plate reveal --------------------------------------------------------
Characters start once the row is mostly open and finish just before the
entering phase ends; long plates get a tighter stagger. */
const REVEAL_AT = 0.36; // of enterMs
const CHAR_SHARE = 0.3; // one char's animation, of enterMs (also in CSS)
const MAX_STEP = 0.045;
export function RevealPlate({ text }) {
const chars = [...text];
const t = LIST_STAGING.enterMs;
const step = Math.min(t * MAX_STEP, (t * (0.97 - REVEAL_AT - CHAR_SHARE)) / Math.max(chars.length - 1, 1));
return (
<span className="vehicle-list-number" aria-label={text}>
{chars.map((c, i) => (
<span
key={i}
className="vehicle-list-char"
aria-hidden="true"
style={{ animationDelay: `${Math.round(t * REVEAL_AT + i * step)}ms` }}
>
{c}
</span>
))}
</span>
);
}
// The opening animates `height` to the row's natural height, measured here
// before the first paint (with its animation briefly off). Module-level so it
// runs once per entering row.
export function measureOpeningRow(el) {
if (!el) return;
el.style.animationName = 'none';
const h = el.offsetHeight;
el.style.animationName = '';
if (h > 0) el.style.setProperty('--vl-row-h', `${h}px`);
}
@@ -0,0 +1,165 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import NavRail from '../NavRail';
import { getLicenseWarning } from '../licenseWarning';
global.IS_REACT_ACT_ENVIRONMENT = true;
const mocks = vi.hoisted(() => ({ user: null, status: null }));
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({ currentUser: mocks.user, logout: () => {} }),
}));
vi.mock('../../contexts/ThemeContext', () => ({
useTheme: () => ({ theme: 'classic', toggleTheme: () => {} }),
}));
vi.mock('../../contexts/LicenseContext', () => ({
useLicense: () => ({ status: mocks.status }),
}));
vi.mock('../Users/ChangePasswordOverlay', () => ({ default: () => null }));
vi.mock('../NomenclatureManager/NomenclatureManager', () => ({ default: () => null }));
const ADMIN = { username: 'admin', is_admin: true, role: 'employee' };
const OPERATOR = { username: 'op', is_admin: false, role: 'employee' };
const GRACE = { valid: false, in_grace: true, grace_days_left: 12, stations: 1 };
const BLOCKED = { valid: false, in_grace: false, grace_days_left: 0, stations: 1 };
const VALID = { valid: true, in_grace: false, grace_days_left: 0, stations: 3 };
describe('getLicenseWarning', () => {
it('returns nothing for a valid or not-yet-loaded licence', () => {
expect(getLicenseWarning(null)).toBeNull();
expect(getLicenseWarning(VALID)).toBeNull();
});
it('builds the grace message with the day count as the emphasis', () => {
const w = getLicenseWarning(GRACE);
expect(w.level).toBe('grace');
expect(w.emphasis).toBe('12 more days');
expect(w.message).toBe(
'This installation is not licensed. Everything keeps working for 12 more days.'
);
expect(`${w.lead}${w.emphasis}${w.tail}`).toBe(w.message);
});
it('builds the blocked message', () => {
const w = getLicenseWarning(BLOCKED);
expect(w.level).toBe('blocked');
expect(w.message).toBe('This installation is not licensed. Only 1 station can measure.');
expect(`${w.lead}${w.emphasis}${w.tail}`).toBe(w.message);
});
});
describe('NavRail licence state', () => {
let container, root;
const renderRail = (user, status) => {
mocks.user = user;
mocks.status = status;
act(() => root.render(<MemoryRouter><NavRail /></MemoryRouter>));
};
const licenceItems = () => [...container.querySelectorAll('.nav-rail-item')]
.filter(b => b.textContent.includes('Licence'));
const popover = () => container.querySelector('.nav-rail-license-popover');
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('shows nothing extra when the licence is valid', () => {
renderRail(ADMIN, VALID);
expect(container.querySelector('.nav-rail-item--license')).toBeNull();
expect(container.querySelector('.nav-rail-badge')).toBeNull();
expect(licenceItems()).toHaveLength(1);
renderRail(OPERATOR, VALID);
expect(licenceItems()).toHaveLength(0);
});
it('puts the admin Licence item into the grace state', () => {
renderRail(ADMIN, GRACE);
const items = licenceItems();
expect(items).toHaveLength(1);
const [item] = items;
expect(item.classList).toContain('nav-rail-item--license-grace');
expect(item.querySelector('.nav-rail-badge')).not.toBeNull();
expect(item.textContent).toContain('🔑');
expect(item.getAttribute('aria-label')).toBe(`Licence: ${getLicenseWarning(GRACE).message}`);
expect(container.querySelector('.nav-rail-bottom .nav-rail-item--license')).toBeNull();
});
it('opens the admin popover on focus, with the bold day count and a page link', () => {
renderRail(ADMIN, GRACE);
const [item] = licenceItems();
act(() => item.focus());
const pop = popover();
expect(pop).not.toBeNull();
expect(pop.getAttribute('role')).toBe('dialog');
expect(pop.querySelector('strong').textContent).toBe('12 more days');
expect(pop.textContent).toContain(getLicenseWarning(GRACE).message);
expect(pop.querySelector('button').textContent).toBe('Open licence page');
expect(item.getAttribute('aria-describedby')).toBe(pop.id);
act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })));
expect(popover()).toBeNull();
});
it('marks the admin item as blocked', () => {
renderRail(ADMIN, BLOCKED);
const [item] = licenceItems();
expect(item.classList).toContain('nav-rail-item--license-blocked');
expect(item.querySelector('.nav-rail-badge')).not.toBeNull();
});
it('gives a non-admin a "!" marker at the bottom, without a page link', () => {
renderRail(OPERATOR, BLOCKED);
const items = licenceItems();
expect(items).toHaveLength(1);
const [item] = items;
expect(item.closest('.nav-rail-bottom')).not.toBeNull();
expect(item.classList).toContain('nav-rail-item--license-blocked');
expect(item.querySelector('.nav-rail-icon').textContent).toBe('!');
act(() => item.click());
const pop = popover();
expect(pop.getAttribute('role')).toBe('tooltip');
expect(pop.textContent).toBe(getLicenseWarning(BLOCKED).message);
expect(pop.querySelector('button')).toBeNull();
act(() => document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })));
expect(popover()).toBeNull();
});
it('uses the grace state for a non-admin in grace', () => {
renderRail(OPERATOR, GRACE);
const [item] = licenceItems();
expect(item.classList).toContain('nav-rail-item--license-grace');
});
it('opens on hover only after the delay, and closes after leaving', () => {
vi.useFakeTimers();
try {
renderRail(OPERATOR, GRACE);
const anchor = licenceItems()[0].closest('.nav-rail-anchor');
act(() => anchor.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
act(() => vi.advanceTimersByTime(100));
expect(popover()).toBeNull();
act(() => vi.advanceTimersByTime(150));
expect(popover()).not.toBeNull();
act(() => anchor.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body })));
expect(popover()).not.toBeNull();
act(() => vi.advanceTimersByTime(300));
expect(popover()).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,136 @@
import { act } from 'react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createRoot } from 'react-dom/client';
import { LIST_STAGING, PendingMarker, RevealPlate, REHOLD_PX, shouldHoldList } from '../VehicleListStaging';
global.IS_REACT_ACT_ENVIRONMENT = true;
describe('VehicleListStaging', () => {
let container, root;
const render = el => act(() => root.render(el));
beforeEach(() => {
container = document.createElement('div');
root = createRoot(container);
});
afterEach(() => act(() => root.unmount()));
it('morphs the marker between frames of one shape, lasting distortMs', () => {
render(<PendingMarker reduceMotion={false} />);
const anim = container.querySelector('animate');
const frames = anim.getAttribute('values').split(';');
const commands = d => d.replace(/[^A-Za-z]/g, '');
const numbers = d => d.match(/-?\d+(\.\d+)?/g).map(Number);
expect(new Set(frames.map(commands)).size).toBe(1);
expect(new Set(frames.map(f => numbers(f).length)).size).toBe(1);
// Every point stays inside the 12px-high viewBox.
for (const f of frames) {
numbers(f).filter((_, i) => i % 2 === 1).forEach(y => {
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(12);
});
}
expect(anim.getAttribute('keyTimes').split(';')).toHaveLength(frames.length);
expect(anim.getAttribute('dur')).toBe(`${LIST_STAGING.distortMs}ms`);
});
it('starts the distortion only when told to, not at a time after mount', () => {
render(<PendingMarker reduceMotion={false} />);
const marker = container.querySelector('.vehicle-list-marker');
const anim = container.querySelector('animate');
expect(anim.getAttribute('begin')).toBe('indefinite');
expect(marker.classList.contains('vehicle-list-marker--distorting')).toBe(false);
// jsdom has no SMIL; stand in for SVGAnimationElement.beginElement.
anim.beginElement = vi.fn();
render(<PendingMarker reduceMotion={false} />);
expect(anim.beginElement).not.toHaveBeenCalled();
render(<PendingMarker reduceMotion={false} distorting />);
expect(container.querySelector('animate')).toBe(anim); // same element, not remounted
expect(anim.beginElement).toHaveBeenCalledTimes(1);
expect(marker.classList.contains('vehicle-list-marker--distorting')).toBe(true);
render(<PendingMarker reduceMotion={false} distorting />);
expect(anim.beginElement).toHaveBeenCalledTimes(1);
});
it('keeps the marker static when motion is reduced', () => {
render(<PendingMarker reduceMotion />);
expect(container.querySelector('path')).not.toBeNull();
expect(container.querySelector('animate')).toBeNull();
render(<PendingMarker reduceMotion distorting />); // no SMIL to start
expect(container.querySelector('animate')).toBeNull();
});
it('splits the plate into hidden, staggered chars under one label', () => {
render(<RevealPlate text="CA 1234 AB" />);
const plate = container.querySelector('.vehicle-list-number');
const chars = [...plate.querySelectorAll('.vehicle-list-char')];
expect(plate.getAttribute('aria-label')).toBe('CA 1234 AB');
expect(chars.map(c => c.textContent).join('')).toBe('CA 1234 AB');
expect(chars.every(c => c.getAttribute('aria-hidden') === 'true')).toBe(true);
const delays = chars.map(c => parseInt(c.style.animationDelay, 10));
delays.slice(1).forEach((d, i) => expect(d).toBeGreaterThan(delays[i]));
// The last char finishes within the entering phase (30% per char).
expect(delays.at(-1) + LIST_STAGING.enterMs * 0.3).toBeLessThanOrEqual(LIST_STAGING.enterMs);
});
it('fits a long plate into the entering phase too', () => {
render(<RevealPlate text="ABCDEFGHIJKLMNOPQRST" />);
const chars = [...container.querySelectorAll('.vehicle-list-char')];
const last = parseInt(chars.at(-1).style.animationDelay, 10);
expect(last + LIST_STAGING.enterMs * 0.3).toBeLessThanOrEqual(LIST_STAGING.enterMs);
});
describe('shouldHoldList', () => {
const move = (clientX, clientY, pointerType = 'mouse') => ({ pointerType, clientX, clientY });
it('holds for a mouse or pen when no click released the hold', () => {
expect(shouldHoldList(move(10, 10), null)).toBe(true);
expect(shouldHoldList(move(10, 10, 'pen'), null)).toBe(true);
});
it('never holds for touch', () => {
expect(shouldHoldList(move(10, 10, 'touch'), null)).toBe(false);
expect(shouldHoldList(move(100, 100, 'touch'), { x: 0, y: 0 })).toBe(false);
});
it('after a click, holds again only REHOLD_PX away from the click point', () => {
const at = { x: 50, y: 50 };
expect(REHOLD_PX).toBe(4);
expect(shouldHoldList(move(50, 50), at)).toBe(false);
expect(shouldHoldList(move(53, 50), at)).toBe(false);
expect(shouldHoldList(move(52, 52), at)).toBe(false); // ~2.83px
expect(shouldHoldList(move(54, 50), at)).toBe(true);
expect(shouldHoldList(move(50, 46), at)).toBe(true);
expect(shouldHoldList(move(53, 53), at)).toBe(true); // ~4.24px
});
});
describe('overlay marker CSS', () => {
// Vitest stubs CSS imports (even ?raw), so read the file itself.
const mainCss = readFileSync(resolve(__dirname, '../Main.css'), 'utf8');
// The body of the top-level rule whose selector is exactly `sel`.
const rule = sel => {
const start = mainCss.indexOf(`\n${sel} {`);
return start < 0 ? '' : mainCss.slice(start, mainCss.indexOf('}', start));
};
it('gives the marker no layout height and draws its 12px line over the row border', () => {
expect(rule('.vehicle-list-marker')).toMatch(/height: 0;/);
expect(rule('.vehicle-list-marker')).toMatch(/pointer-events: none;/);
expect(rule('.vehicle-list-marker')).toMatch(/z-index: \d+;/);
const line = rule('.vehicle-list-marker-line');
expect(line).toMatch(/position: absolute;/);
expect(line).toMatch(/top: -6px;/);
expect(line).toMatch(/height: 12px;/);
});
it('opens the new row from height 0', () => {
const open = mainCss.match(/@keyframes vehicle-list-open \{\s*0% \{([^}]*)\}/);
expect(open[1]).toMatch(/height: 0;/);
});
});
});
+21
View File
@@ -0,0 +1,21 @@
// What the rail shows for a licence status from useLicense(): null when there
// is nothing to warn about, otherwise the level and the message split around
// the part shown in bold.
export function getLicenseWarning(status) {
if (!status || status.valid) return null;
const lead = 'This installation is not licensed. ';
if (status.in_grace) {
const emphasis = `${status.grace_days_left} more days`;
return {
level: 'grace',
lead: `${lead}Everything keeps working for `,
emphasis,
tail: '.',
message: `${lead}Everything keeps working for ${emphasis}.`,
};
}
const text = `${lead}Only ${status.stations} station can measure.`;
return { level: 'blocked', lead: text, emphasis: '', tail: '', message: text };
}
+19 -31
View File
@@ -6,7 +6,7 @@ const NomenclatureContext = createContext();
// Centralized nomenclature configuration
const NOMENCLATURES_CONFIG = [
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'name', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'vehicle_number', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
// { key: 'vesselOwners', jsonKey: 'vessel_owners', sortBy: 'name', endpoint: '/vessel_owners/api/list/', sseObjectType: 'vessel_owner' },
// { key: 'agents', jsonKey: 'agents', sortBy: 'name', endpoint: '/agents/api/list/', sseObjectType: 'agent' },
// { key: 'objects', jsonKey: 'objects', sortBy: 'id', endpoint: '/objects/api/list/', sseObjectType: 'object' },
@@ -42,21 +42,25 @@ export const useNomenclatures = () => {
return context;
};
// Compare two items by the configured sort field. 'id' compares numerically;
// any other field compares as text, case-insensitive with natural number
// order (so "CA 2" sorts before "CA 10"). Missing values sort as empty text.
const compareBy = (a, b, orderBy) => {
if (orderBy === 'id') {
return (a.id || 0) - (b.id || 0);
}
return String(a[orderBy] ?? '').localeCompare(String(b[orderBy] ?? ''), undefined, {
numeric: true,
sensitivity: 'base',
});
};
// Utility function to sort nomenclature array based on configuration
const sortNomenclature = (array, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
return [...array].sort((a, b) => {
if (orderBy === 'id') {
return (a.id || 0) - (b.id || 0);
} else {
// Order by name (case-insensitive)
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
}
});
return [...array].sort((a, b) => compareBy(a, b, orderBy));
};
// Utility function to insert item in sorted position
@@ -65,23 +69,9 @@ const insertSorted = (array, newItem, nomenclatureKey) => {
const orderBy = config?.sortBy || 'name';
const newArray = [...array];
// Find the correct insertion position
let insertIndex = newArray.length;
if (orderBy === 'id') {
insertIndex = newArray.findIndex(item => {
const comparison = item.id > newItem.id;
return comparison;
});
if (insertIndex === -1) insertIndex = newArray.length;
} else {
const newName = (newItem.name || '').toLowerCase();
insertIndex = newArray.findIndex(item =>
(item.name || '').toLowerCase() > newName
);
if (insertIndex === -1) insertIndex = newArray.length;
}
// Insert before the first item that sorts after the new one
let insertIndex = newArray.findIndex(item => compareBy(item, newItem, orderBy) > 0);
if (insertIndex === -1) insertIndex = newArray.length;
newArray.splice(insertIndex, 0, newItem);
return newArray;
@@ -102,9 +92,7 @@ const updateSorted = (array, updatedItem, nomenclatureKey) => {
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
}
const orderFieldChanged = orderBy === 'id'
? oldItem.id !== updatedItem.id
: (oldItem.name || '').toLowerCase() !== (updatedItem.name || '').toLowerCase();
const orderFieldChanged = compareBy(oldItem, updatedItem, orderBy) !== 0;
if (orderFieldChanged) {
// Re-insert in sorted position if the ordering field changed
@@ -0,0 +1,334 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import useStagedList, { reconcileStaged, advanceStaged, initialStagedState } from '../useStagedList';
global.IS_REACT_ACT_ENVIRONMENT = true;
const v = (id, extra = {}) => ({ id, vehicle_number: `CA${id}`, ...extra });
const getKey = x => x.id;
const view = entries => entries.map(e => `${e.phase}:${e.key}`);
describe('useStagedList (hook)', () => {
let container, root, latest;
function Harness({ items, query = '', options }) {
const entries = useStagedList(items, { getKey, ...options });
latest = query ? entries.filter(e => e.item.vehicle_number.includes(query)) : entries;
return null;
}
const render = (items, query, options) =>
act(() => root.render(<Harness items={items} query={query} options={options} />));
const tick = ms => act(() => { vi.advanceTimersByTime(ms); });
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement('div');
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
vi.useRealTimers();
});
it('shows the initial load immediately, including empty -> many', () => {
render([]);
expect(view(latest)).toEqual([]);
render([v(1), v(2), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
expect(vi.getTimerCount()).toBe(0);
});
it('shows a marker for an added item, then the row after the delay', () => {
render([v(1), v(3)]);
render([v(1), v(2), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'pending:2', 'stable:3']);
tick(2999);
expect(view(latest)).toEqual(['stable:1', 'pending:2', 'stable:3']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'entering:2', 'stable:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
});
it('keeps a removed item as leaving, then removes it', () => {
render([v(1), v(2), v(3)]);
render([v(1), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'leaving:2', 'stable:3']);
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'stable:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:3']);
});
it('gives overlapping changes independent timers', () => {
render([v(1), v(3), v(5)]);
render([v(1), v(2), v(3), v(5)]);
tick(1500);
render([v(1), v(2), v(3), v(4)]); // add 4, remove 5, 1.5s later
expect(view(latest)).toEqual(['stable:1', 'pending:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(1500);
expect(view(latest)).toEqual(['stable:1', 'entering:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(1500);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3', 'exiting:5', 'entering:4']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3', 'stable:4']);
});
it('times entering with enterMs and exiting with animationMs', () => {
const options = { enterMs: 800, animationMs: 280 };
render([v(1), v(2)], '', options);
render([v(1), v(3)], '', options); // remove 2, add 3
expect(view(latest)).toEqual(['stable:1', 'leaving:2', 'pending:3']);
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'entering:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'entering:3']);
tick(519);
expect(view(latest)).toEqual(['stable:1', 'entering:3']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'stable:3']);
});
it('skips the entering phase when enterMs is 0, keeping the exit animation', () => {
const options = { enterMs: 0, animationMs: 280 };
render([v(1), v(2)], '', options);
render([v(1), v(3)], '', options);
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'stable:3']);
});
it('cancels a pending add that is removed before the delay', () => {
render([v(1)]);
render([v(1), v(2)]);
tick(1000);
render([v(1)]);
expect(view(latest)).toEqual(['stable:1']);
tick(5000);
expect(view(latest)).toEqual(['stable:1']);
});
it('returns a leaving item to normal when it comes back', () => {
render([v(1), v(2), v(3)]);
render([v(1), v(3)]);
tick(1000);
render([v(1), v(2), v(3)]);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
tick(5000);
expect(view(latest)).toEqual(['stable:1', 'stable:2', 'stable:3']);
});
it('applies content updates in place immediately', () => {
render([v(1), v(2)]);
render([v(1), v(2, { tare: 12000 })]);
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
expect(latest[1].item.tare).toBe(12000);
});
it('applies a filter change instantly, without staging', () => {
const items = [v(1), v(12), v(3)];
render(items);
render(items, 'CA1');
expect(view(latest)).toEqual(['stable:1', 'stable:12']);
render(items, '');
expect(view(latest)).toEqual(['stable:1', 'stable:12', 'stable:3']);
expect(vi.getTimerCount()).toBe(0);
});
it('distorts a marker for the last distortMs before it enters', () => {
const options = { distortMs: 650 };
render([v(1)], '', options);
render([v(1), v(2)], '', options);
tick(2349);
expect(view(latest)).toEqual(['stable:1', 'pending:2']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
tick(649);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
tick(1);
expect(view(latest)).toEqual(['stable:1', 'entering:2']);
});
describe('hold', () => {
const held = { distortMs: 650, hold: true };
const free = { distortMs: 650, hold: false };
it('keeps a pending entry calm past its delay, then distorts and enters on release', () => {
render([v(1)], '', held);
render([v(1), v(2)], '', held);
tick(10000);
expect(view(latest)).toEqual(['stable:1', 'pending:2']);
render([v(1), v(2)], '', free);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
tick(650);
expect(view(latest)).toEqual(['stable:1', 'entering:2']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
});
it('lets a pending entry finish its calm time normally if released before it is due', () => {
render([v(1)], '', held);
render([v(1), v(2)], '', held);
tick(1000);
render([v(1), v(2)], '', free);
expect(view(latest)).toEqual(['stable:1', 'pending:2']);
tick(1350);
expect(view(latest)).toEqual(['stable:1', 'distorting:2']);
});
it('keeps a leaving entry leaving past its delay, then exits on release', () => {
render([v(1), v(2)], '', held);
render([v(1)], '', held);
tick(10000);
expect(view(latest)).toEqual(['stable:1', 'leaving:2']);
render([v(1)], '', free);
expect(view(latest)).toEqual(['stable:1', 'exiting:2']);
tick(280);
expect(view(latest)).toEqual(['stable:1']);
});
it('does not freeze entries already distorting, entering or exiting', () => {
render([v(1), v(9)], '', free);
render([v(1), v(2), v(3)], '', free); // add 2 and 3, remove 9
tick(2350);
render([v(1), v(2), v(3)], '', held);
expect(view(latest)).toEqual(['stable:1', 'leaving:9', 'distorting:2', 'distorting:3']);
tick(650); // 9 is due now, but held
expect(view(latest)).toEqual(['stable:1', 'leaving:9', 'entering:2', 'entering:3']);
tick(280);
expect(view(latest)).toEqual(['stable:1', 'leaving:9', 'stable:2', 'stable:3']);
render([v(1), v(3)], '', free); // 9 exits, 2 leaves
tick(3000);
expect(view(latest)).toEqual(['stable:1', 'exiting:2', 'stable:3']);
render([v(1), v(3)], '', held); // hold while 2 is exiting
tick(280);
expect(view(latest)).toEqual(['stable:1', 'stable:3']);
});
it('drops a pending entry removed while held, and restores a leaving one re-added', () => {
render([v(1), v(2)], '', held);
render([v(1), v(3)], '', held); // remove 2, add 3
tick(5000);
expect(view(latest)).toEqual(['stable:1', 'leaving:2', 'pending:3']);
render([v(1), v(2)], '', held); // 3 cancelled, 2 back
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
render([v(1), v(2)], '', free);
tick(5000);
expect(view(latest)).toEqual(['stable:1', 'stable:2']);
expect(vi.getTimerCount()).toBe(0);
});
it('applies a bulk update immediately while held', () => {
render([v(1), v(2)], '', held);
render([v(1)], '', held);
tick(5000);
const many = Array.from({ length: 12 }, (_, i) => v(100 + i));
render(many, '', held);
expect(latest.every(e => e.phase === 'stable')).toBe(true);
expect(latest.map(e => e.key)).toEqual(many.map(getKey));
});
it('releases several overdue entries top to bottom, releaseStagger apart', () => {
const opts = hold => ({ ...held, hold, releaseStagger: 70 });
render([v(1), v(3), v(5)], '', opts(true));
render([v(2), v(3), v(4)], '', opts(true)); // -1 +2, +4 -5
tick(5000);
expect(view(latest)).toEqual(['leaving:1', 'pending:2', 'stable:3', 'leaving:5', 'pending:4']);
render([v(2), v(3), v(4)], '', opts(false));
expect(view(latest)).toEqual(['exiting:1', 'pending:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(70);
expect(view(latest)).toEqual(['exiting:1', 'distorting:2', 'stable:3', 'leaving:5', 'pending:4']);
tick(70);
expect(view(latest)).toEqual(['exiting:1', 'distorting:2', 'stable:3', 'exiting:5', 'pending:4']);
render([v(2), v(3), v(4)], '', opts(true)); // held again mid-stagger: 4 keeps waiting
tick(580); // 2 enters, 5 is gone
tick(280); // 2 settles
tick(5000);
expect(view(latest)).toEqual(['stable:2', 'stable:3', 'pending:4']);
render([v(2), v(3), v(4)], '', opts(false));
expect(view(latest)).toEqual(['stable:2', 'stable:3', 'distorting:4']);
});
it('releases all overdue entries at once without a stagger', () => {
render([v(1), v(3)], '', held);
render([v(2), v(4)], '', held);
tick(5000);
render([v(2), v(4)], '', free);
expect(view(latest)).toEqual(['exiting:1', 'exiting:3', 'distorting:2', 'distorting:4']);
});
});
it('clears all timers on unmount', () => {
render([v(1)]);
render([v(2)]);
expect(vi.getTimerCount()).toBeGreaterThan(0);
act(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
root = createRoot(container); // afterEach unmounts again
});
});
describe('reconcileStaged / advanceStaged (pure)', () => {
const start = items => reconcileStaged(initialStagedState, items, getKey);
it('treats a re-sort as a move: leaving at the old spot, marker at the new', () => {
const s0 = start([v(1), v(2), v(3)]);
const s1 = reconcileStaged(s0, [v(2), v(3), v(1)], getKey);
expect(view(s1.entries)).toEqual(['leaving:1', 'stable:2', 'stable:3', 'pending:1']);
// The two entries for key 1 have distinct ids (React keys).
expect(s1.entries[0].id).not.toBe(s1.entries[3].id);
});
it('applies bulk replacements without staging', () => {
const s0 = start([v(1), v(2)]);
const many = Array.from({ length: 12 }, (_, i) => v(100 + i));
const s1 = reconcileStaged(s0, many, getKey, { bulkThreshold: 10 });
expect(s1.entries.every(e => e.phase === 'stable')).toBe(true);
expect(s1.entries.map(e => e.key)).toEqual(many.map(getKey));
});
it('returns the same state when nothing changed', () => {
const items = [v(1), v(2)];
const s0 = start(items);
expect(reconcileStaged(s0, items, getKey)).toBe(s0);
});
it('skips the animation phases when motion is reduced', () => {
const s0 = start([v(1)]);
const s1 = reconcileStaged(s0, [v(2)], getKey);
expect(view(s1.entries)).toEqual(['leaving:1', 'pending:2']);
const [leaving, pending] = s1.entries;
const s2 = advanceStaged(s1, pending.id, 'pending', false);
const s3 = advanceStaged(s2, leaving.id, 'leaving', false);
expect(view(s3.entries)).toEqual(['stable:2']);
});
it('sends a pending entry through distorting when asked to', () => {
const s0 = start([v(1)]);
const s1 = reconcileStaged(s0, [v(1), v(2)], getKey);
const id = s1.entries[1].id;
const s2 = advanceStaged(s1, id, 'pending', true, true);
expect(view(s2.entries)).toEqual(['stable:1', 'distorting:2']);
expect(view(advanceStaged(s2, id, 'distorting').entries)).toEqual(['stable:1', 'entering:2']);
expect(view(advanceStaged(s2, id, 'distorting', false).entries)).toEqual(['stable:1', 'stable:2']);
});
it('treats a distorting marker like a pending one: it moves along, or is dropped', () => {
const s0 = start([v(1), v(3), v(4)]);
const s1 = reconcileStaged(s0, [v(1), v(2), v(3), v(4)], getKey);
const s2 = advanceStaged(s1, s1.entries[1].id, 'pending', true, true);
const moved = reconcileStaged(s2, [v(1), v(3), v(4), v(2)], getKey);
expect(view(moved.entries)).toEqual(['stable:1', 'stable:3', 'stable:4', 'distorting:2']);
expect(moved.entries[3].id).toBe(s2.entries[1].id);
expect(view(reconcileStaged(s2, [v(1), v(3), v(4)], getKey).entries))
.toEqual(['stable:1', 'stable:3', 'stable:4']);
});
it('ignores a stale timer for an entry whose phase changed', () => {
const s0 = start([v(1)]);
const s1 = reconcileStaged(s0, [], getKey);
const id = s1.entries[0].id;
const s2 = reconcileStaged(s1, [v(1)], getKey); // restored
expect(advanceStaged(s2, id, 'leaving')).toBe(s2);
});
});
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useReducer, useRef } from 'react';
/**
* Display layer for a live, sorted list that changes under the user's cursor.
*
* Returns entries `{ id, key, item, phase }` in display order, lagging the
* live list so rows never move without warning:
*
* pending - item is in the live list but not shown yet; render a marker
* where it will appear. After `delay - distortMs` -> distorting
* (or, with `distortMs` = 0, after `delay` -> entering).
* distorting - the marker's last moment before the row opens. After
* `distortMs` -> entering.
* entering - row is animating in. After `enterMs` -> stable.
* stable - normal row.
* leaving - row is gone from the live list but kept in place, marked.
* After `delay` -> exiting.
* exiting - row is animating out. After `animationMs` it is dropped.
*
* `enterMs` defaults to `animationMs`; a value of 0 skips the entering phase
* (and `animationMs` = 0 skips exiting).
*
* `hold` (read live) parks the list while the user is pointing at it: a
* pending entry whose time is up stays pending (no distortion) and a leaving
* one stays leaving. Entries already distorting, entering or exiting finish.
* When `hold` turns false the overdue entries proceed in display order,
* `releaseStagger` ms apart.
*
* A row whose sort position changes becomes a `leaving` ghost at the old spot
* plus a `pending` entry at the new one. Each entry has its own timer.
* Content updates (same key, same position) apply immediately.
*
* Not staged: the first non-empty data (or any time nothing was shown), and
* bulk replacements touching more than `bulkThreshold` rows (a full reload).
* With prefers-reduced-motion the distorting/entering/exiting phases are
* skipped; the pending/leaving warning still lasts `delay`.
*
* `id` is unique per entry (a moved item briefly has two), use it as the
* React key. `getKey` may be an inline function.
*/
const LIVE_PHASES = new Set(['pending', 'distorting', 'entering', 'stable']);
const HOLDABLE = new Set(['pending', 'leaving']);
const isMarker = phase => phase === 'pending' || phase === 'distorting';
const HEAD = Symbol('head');
export const initialStagedState = { entries: [], nextId: 1 };
// Indices (into `seq`) of one longest strictly increasing subsequence.
function longestIncreasing(seq) {
const tails = []; // tails[len] = index in seq of the smallest tail for length len+1
const prev = new Array(seq.length);
for (let i = 0; i < seq.length; i++) {
let lo = 0, hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (seq[tails[mid]] < seq[i]) lo = mid + 1; else hi = mid;
}
prev[i] = lo > 0 ? tails[lo - 1] : -1;
tails[lo] = i;
}
const result = new Set();
for (let i = tails.length ? tails[tails.length - 1] : -1; i !== -1; i = prev[i]) result.add(i);
return result;
}
function sameEntries(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i], y = b[i];
if (x.id !== y.id || x.phase !== y.phase || x.item !== y.item) return false;
}
return true;
}
/** Merge a new live list into the displayed state. Pure. */
export function reconcileStaged(state, items, getKey, { bulkThreshold = 10 } = {}) {
const prev = state.entries;
let nextId = state.nextId;
const liveKeys = items.map(getKey);
const liveMap = new Map();
liveKeys.forEach((k, i) => liveMap.set(k, items[i]));
const liveIndex = new Map(liveKeys.map((k, i) => [k, i]));
// The entry currently representing each key at its live position. A
// `leaving` ghost whose key came back is a candidate to be restored.
const current = new Map();
for (const e of prev) if (LIVE_PHASES.has(e.phase)) current.set(e.key, e);
for (const e of prev) {
if (e.phase === 'leaving' && liveMap.has(e.key) && !current.has(e.key)) current.set(e.key, e);
}
// Survivors (still live) in previous display order; those on a longest
// increasing run of new positions stay put, the rest have moved.
const survivors = prev.filter(e => current.get(e.key) === e && liveMap.has(e.key));
const staySet = longestIncreasing(survivors.map(e => liveIndex.get(e.key)));
const staying = new Set(survivors.filter((_, i) => staySet.has(i)).map(e => e.key));
const added = liveKeys.filter(k => !current.has(k)).length;
const removed = [...current.values()].filter(e => e.phase !== 'leaving' && !liveMap.has(e.key)).length;
const moved = survivors.length - staying.size;
const hadRows = prev.some(e => LIVE_PHASES.has(e.phase));
if (!hadRows || added + removed + moved > bulkThreshold) {
const entries = liveKeys.map(k => ({
id: current.get(k)?.id ?? nextId++,
key: k,
item: liveMap.get(k),
phase: 'stable',
}));
const next = { entries, nextId };
return sameEntries(prev, entries) ? state : next;
}
const placed = new Map();
const ghostsAfter = new Map();
const addGhost = (anchor, entry) => {
if (!ghostsAfter.has(anchor)) ghostsAfter.set(anchor, []);
ghostsAfter.get(anchor).push(entry);
};
let anchor = HEAD;
for (const e of prev) {
const isCurrent = current.get(e.key) === e;
if (isCurrent && liveMap.has(e.key)) {
const item = liveMap.get(e.key);
if (staying.has(e.key)) {
placed.set(e.key, { ...e, item, phase: e.phase === 'leaving' ? 'stable' : e.phase });
anchor = e.key;
} else if (isMarker(e.phase)) {
placed.set(e.key, { ...e, item }); // marker just moves, its timer keeps running
} else {
// Moved: warn at the old spot, marker at the new one.
addGhost(anchor, e.phase === 'leaving' ? e : { ...e, item, phase: 'leaving' });
placed.set(e.key, { id: nextId++, key: e.key, item, phase: 'pending' });
}
} else if (isCurrent) {
// Gone from the live list. A pending add is simply cancelled.
if (!isMarker(e.phase)) addGhost(anchor, e.phase === 'leaving' ? e : { ...e, phase: 'leaving' });
} else if (e.phase === 'leaving' || e.phase === 'exiting') {
addGhost(anchor, e);
}
}
const entries = [...(ghostsAfter.get(HEAD) || [])];
for (const k of liveKeys) {
entries.push(placed.get(k) || { id: nextId++, key: k, item: liveMap.get(k), phase: 'pending' });
if (ghostsAfter.has(k)) entries.push(...ghostsAfter.get(k));
}
return sameEntries(prev, entries) ? state : { entries, nextId };
}
/** Move one entry to its next phase when its timer fires. Pure. `distort`
* sends a pending entry to distorting instead of straight to entering. */
export function advanceStaged(state, id, phase, animate = true, distort = false) {
const index = state.entries.findIndex(e => e.id === id);
if (index === -1 || state.entries[index].phase !== phase) return state;
const NEXT = animate
? { pending: distort ? 'distorting' : 'entering', distorting: 'entering', entering: 'stable', leaving: 'exiting', exiting: null }
: { pending: 'stable', distorting: 'stable', entering: 'stable', leaving: null, exiting: null };
const nextPhase = NEXT[phase];
const entries = nextPhase
? state.entries.map((e, i) => (i === index ? { ...e, phase: nextPhase } : e))
: state.entries.filter((_, i) => i !== index);
return { ...state, entries };
}
function reducer(state, action) {
switch (action.type) {
case 'sync':
return reconcileStaged(state, action.items, action.getKey, action.options);
case 'advance':
return advanceStaged(state, action.id, action.phase, action.animate, action.distort);
default:
return state;
}
}
export const prefersReducedMotion = () =>
typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
export default function useStagedList(items, {
getKey, delay = 3000, animationMs = 280, enterMs = animationMs, distortMs = 0, bulkThreshold = 10,
hold = false, releaseStagger = 0,
} = {}) {
const getKeyRef = useRef(getKey);
getKeyRef.current = getKey;
const holdRef = useRef(hold);
holdRef.current = hold;
const [state, dispatch] = useReducer(reducer, undefined,
() => reconcileStaged(initialStagedState, items, getKey, { bulkThreshold }));
useEffect(() => {
dispatch({ type: 'sync', items, getKey: getKeyRef.current, options: { bulkThreshold } });
}, [items, bulkThreshold]);
const entriesRef = useRef(state.entries);
entriesRef.current = state.entries;
// One timer per entry in a timed phase; kept while the entry stays in
// that phase, so a new change never resets the others. A pending/leaving
// timer that fires while held marks its entry overdue (no handle) and
// waits for the release below.
const timersRef = useRef(new Map());
useEffect(() => {
const timers = timersRef.current;
const wanted = new Map();
for (const e of state.entries) {
if (e.phase !== 'stable') wanted.set(e.id, e.phase);
}
for (const [id, t] of timers) {
if (wanted.get(id) !== t.phase) {
clearTimeout(t.handle);
timers.delete(id);
}
}
for (const [id, phase] of wanted) {
if (timers.has(id)) continue;
const distort = phase === 'pending' && distortMs > 0 && !prefersReducedMotion();
const ms = {
pending: distort ? delay - distortMs : delay,
distorting: distortMs, leaving: delay, entering: enterMs, exiting: animationMs,
}[phase];
// The phase this timer leads into, if animated, lasts this long.
const nextMs = { pending: distort ? distortMs : enterMs, distorting: enterMs }[phase] ?? animationMs;
const t = { phase, handle: null, overdue: false };
t.fire = () => {
t.handle = null;
if (holdRef.current && HOLDABLE.has(phase)) {
t.overdue = true;
return;
}
timers.delete(id);
dispatch({ type: 'advance', id, phase, animate: nextMs > 0 && !prefersReducedMotion(), distort });
};
t.handle = setTimeout(t.fire, ms);
timers.set(id, t);
}
}, [state.entries, delay, animationMs, enterMs, distortMs]);
// Released: overdue entries go on, top to bottom. A staggered one that
// is still waiting when the hold returns becomes overdue again.
useEffect(() => {
if (hold) return;
const timers = timersRef.current;
let wait = 0;
for (const e of entriesRef.current) {
const t = timers.get(e.id);
if (!t?.overdue) continue;
t.overdue = false;
if (wait === 0) t.fire();
else t.handle = setTimeout(t.fire, wait);
wait += releaseStagger;
}
}, [hold, releaseStagger]);
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const t of timers.values()) clearTimeout(t.handle);
timers.clear();
};
}, []);
return state.entries;
}
@@ -0,0 +1,42 @@
import { buildVehicleReportData, vehicleNetWeight } from '../vehicleReportData';
describe('vehicleNetWeight', () => {
test('null vehicle has no net', () => {
expect(vehicleNetWeight(null)).toBeNull();
});
test('two weighings without a server net derive gross - tare', () => {
expect(vehicleNetWeight({ tare: 12000, gross: 28500, net: null })).toBe(16500);
});
test('the server net wins over the derived value', () => {
expect(vehicleNetWeight({ tare: 12000, gross: 28500, net: 16400 })).toBe(16400);
});
test('a one-weight vehicle (tare null, net = gross) uses net', () => {
expect(vehicleNetWeight({ tare: null, gross: 28500, net: 28500 })).toBe(28500);
});
test('a single open weighing has no net', () => {
expect(vehicleNetWeight({ tare: 12000, gross: null, net: null })).toBeNull();
expect(vehicleNetWeight({ tare: null, gross: 28500, net: null })).toBeNull();
});
});
describe('buildVehicleReportData', () => {
test('a one-weight vehicle reports net = gross and keeps tare null', () => {
const out = buildVehicleReportData(
{ id: 1, tare: null, gross: 28500, net: 28500, doc_number: '2026-001', extra: { data: {} } },
{},
{}
);
expect(out.vehicle.net).toBe(28500);
expect(out.vehicle.tare).toBeNull();
expect(out.vehicle.gross).toBe(28500);
});
test('two weighings without a server net still derive net', () => {
const out = buildVehicleReportData({ id: 2, tare: 12000, gross: 28500 }, {}, {});
expect(out.vehicle.net).toBe(16500);
});
});
+17 -4
View File
@@ -1,3 +1,19 @@
/**
* Net weight of a vehicle: the server's `net` when set, otherwise gross - tare
* when both are recorded, otherwise null. The server value comes first because
* a one-weight vehicle is finalized with tare = null and net = gross.
*
* @param {Object|null} vehicle
* @returns {number|null}
*/
export function vehicleNetWeight(vehicle) {
if (!vehicle) return null;
if (vehicle.net != null) return vehicle.net;
return (vehicle.tare != null && vehicle.gross != null)
? vehicle.gross - vehicle.tare
: null;
}
/**
* Shapes a vehicle for the report preview/design/print machinery.
*
@@ -26,13 +42,10 @@ export function buildVehicleReportData(vehicle, definitions, entries) {
extra[def.name] = value ?? '';
}
}
const netWeight = (vehicle.tare != null && vehicle.gross != null)
? vehicle.gross - vehicle.tare
: null;
return {
vehicle: {
...vehicle,
net: vehicle.net ?? netWeight,
net: vehicleNetWeight(vehicle),
extra,
}
};
+4
View File
@@ -82,6 +82,9 @@
--header-chrome-border: rgba(255, 255, 255, 0.3);
--header-avatar-bg: #ffffff;
--header-avatar-text: #667eea;
/* Active-item edge and focus ring on the rail. Not --accent: the accent is
the gradient's own start colour, so it would vanish on this bar. */
--header-active: #ffffff;
/* Cockpit-only chrome, neutralised here so shared rules need no theme test */
--hero-image: none;
@@ -150,6 +153,7 @@
--header-chrome-border: rgba(0, 217, 255, 0.34);
--header-avatar-bg: #00d9ff;
--header-avatar-text: #00232e;
--header-active: var(--accent);
/* Wide crop of the reference photograph, shown beside the vehicle title in
.vehicle-hero-photo, scaled proportionally (background-size: contain)
+1
View File
@@ -2,6 +2,7 @@ Pillow==12.1.0
pyserial==3.5
python-dotenv==1.0.0
flask==3.1.0
flask-cors==5.0.0
requests==2.32.5
urllib3==2.6.3
pystray==0.19.5