diff --git a/backend/api/tests/__init__.py b/backend/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/tests/test_one_weight.py b/backend/api/tests/test_one_weight.py new file mode 100644 index 0000000..62bab16 --- /dev/null +++ b/backend/api/tests/test_one_weight.py @@ -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) diff --git a/backend/api/views.py b/backend/api/views.py index 786a6d6..ce39332 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -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.""" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d0279d..6c54499 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() {

Loading...

}> - } /> - } /> + }> + } /> + } /> + : } /> + : } /> + : } /> + : } /> : } /> - : } /> - : } /> - : } />
diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css deleted file mode 100644 index e29151d..0000000 --- a/frontend/src/components/Header.css +++ /dev/null @@ -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); -} diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx deleted file mode 100644 index 0975f6a..0000000 --- a/frontend/src/components/Header.jsx +++ /dev/null @@ -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 ( - <> -
-
-
- {currentUser?.can_edit_documents && ( -
- - {showEditorMenu && ( - <> - {/* Click-away close, without a document-level listener. */} -
setShowEditorMenu(false)} - /> -
- - -
- - )} -
- )} - {currentUser?.can_manage_entities && ( - - )} - - {currentUser?.is_admin && ( - - )} - {currentUser?.is_admin && ( - - )} - {currentUser?.is_admin && ( - - )} -

ScalesApp - Real-time Data Monitor

-
-
- -
- {currentUser?.username} - ({currentUser?.role}) -
-
-
- {getInitials(currentUser || {})} -
- -
-
-
-
- - {showPasswordOverlay && ( - setShowPasswordOverlay(false)} /> - )} - - {showNomenclatureManager && ( - setShowNomenclatureManager(false)} /> - )} - - ); -} - -export default Header; diff --git a/frontend/src/components/History.jsx b/frontend/src/components/History.jsx index 9d162e8..38ee914 100644 --- a/frontend/src/components/History.jsx +++ b/frontend/src/components/History.jsx @@ -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 (
-

History

diff --git a/frontend/src/components/License/LicenseManager.css b/frontend/src/components/License/LicenseManager.css index bbda742..4c69f0b 100644 --- a/frontend/src/components/License/LicenseManager.css +++ b/frontend/src/components/License/LicenseManager.css @@ -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); -} diff --git a/frontend/src/components/License/LicenseManager.jsx b/frontend/src/components/License/LicenseManager.jsx index 0457ea0..3b935d6 100644 --- a/frontend/src/components/License/LicenseManager.jsx +++ b/frontend/src/components/License/LicenseManager.jsx @@ -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 (
-
Loading licence...
); @@ -50,7 +48,6 @@ export default function LicenseManager() { return (
-
diff --git a/frontend/src/components/Main.css b/frontend/src/components/Main.css index 8b9864f..ab93fdc 100644 --- a/frontend/src/components/Main.css +++ b/frontend/src/components/Main.css @@ -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); -} diff --git a/frontend/src/components/Main.jsx b/frontend/src/components/Main.jsx index 036c428..73852f9 100644 --- a/frontend/src/components/Main.jsx +++ b/frontend/src/components/Main.jsx @@ -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 ( -
-
- {licenseStatus && !licenseStatus.valid && ( -
- {licenseStatus.in_grace - ? <>This installation is not licensed. Everything keeps working for {licenseStatus.grace_days_left} more days. - : <>This installation is not licensed. Only {licenseStatus.stations} station can measure.} -
- )} +
{/* Left panel - Vehicle list */}
@@ -455,20 +468,46 @@ export default function Main() { />
-
- {filteredVehicles.map(v => ( -
{ setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); }} - > - {v.vehicle_number} - {v.tare != null && ( - {v.tare} kg - )} -
- ))} - {filteredVehicles.length === 0 && ( +
+ {filteredVehicles.map(({ id, item: v, phase }) => { + if (phase === 'pending' || phase === 'distorting') { + return ; + } + 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 ( +
{ setSelectedVehicleId(v.id); setShowNewForm(false); setError(''); releaseListHoldOnClick(); } : undefined} + > + {isEntering && !reduceMotion + ? + : {v.vehicle_number}} + {v.tare != null && ( + {v.tare} kg + )} +
+ ); + })} + {!filteredVehicles.some(e => e.phase !== 'pending' && e.phase !== 'distorting') && (
No vehicles found
)}
@@ -748,69 +787,31 @@ export default function Main() {
- {/* 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). */}
- {netWeight !== null && ( - <> - - {/* Split button: the main action routes by - the saved report's kind; the caret opens - an explicit choice for starting the other - kind. */} -
- - - {showDesignMenu && ( - <> - {/* Click-away closes the menu. */} -
setShowDesignMenu(false)} - /> -
- - -
- - )} -
- + {canOneWeight && ( + + )} + {isFinalized && netWeight !== null && ( + )}
diff --git a/frontend/src/components/NavRail.css b/frontend/src/components/NavRail.css new file mode 100644 index 0000000..8218867 --- /dev/null +++ b/frontend/src/components/NavRail.css @@ -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)); + } +} diff --git a/frontend/src/components/NavRail.jsx b/frontend/src/components/NavRail.jsx new file mode 100644 index 0000000..c6ddc06 --- /dev/null +++ b/frontend/src/components/NavRail.jsx @@ -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 ( + + ); +} + +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} + + + ); + + return ( +
+ + {isOpen && ( +
+

+ {warning.lead} + {warning.emphasis && {warning.emphasis}} + {warning.tail} +

+ {isAdmin && ( + + )} +
+ )} +
+ ); +} + +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 ( + <> + + + {showPasswordOverlay && ( + setShowPasswordOverlay(false)} /> + )} + + {showNomenclatureManager && ( + setShowNomenclatureManager(false)} /> + )} + + ); +} + +export default NavRail; diff --git a/frontend/src/components/RailLayout.jsx b/frontend/src/components/RailLayout.jsx new file mode 100644 index 0000000..fb884da --- /dev/null +++ b/frontend/src/components/RailLayout.jsx @@ -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 ( +
+
+ +
+ +
+ ); +} + +export default RailLayout; diff --git a/frontend/src/components/ReportEditor/a4/__tests__/a4EntryPoints.test.js b/frontend/src/components/ReportEditor/a4/__tests__/a4EntryPoints.test.js index fe043b6..646a06e 100644 --- a/frontend/src/components/ReportEditor/a4/__tests__/a4EntryPoints.test.js +++ b/frontend/src/components/ReportEditor/a4/__tests__/a4EntryPoints.test.js @@ -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'); }); diff --git a/frontend/src/components/ScaleMonitor/ScaleMonitor.css b/frontend/src/components/ScaleMonitor/ScaleMonitor.css index 0ea63da..5050cb9 100644 --- a/frontend/src/components/ScaleMonitor/ScaleMonitor.css +++ b/frontend/src/components/ScaleMonitor/ScaleMonitor.css @@ -1,5 +1,5 @@ .scale-monitor { - min-height: 100vh; + flex: 1 0 auto; /* fill the rail layout's content column */ background-color: var(--bg); } diff --git a/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx b/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx index 8e8f47b..eafabd0 100644 --- a/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx +++ b/frontend/src/components/ScaleMonitor/ScaleMonitor.jsx @@ -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 (
-
diff --git a/frontend/src/components/Users/UserManager.jsx b/frontend/src/components/Users/UserManager.jsx index d30abb3..d01e9d4 100644 --- a/frontend/src/components/Users/UserManager.jsx +++ b/frontend/src/components/Users/UserManager.jsx @@ -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 (
-

User Manager

diff --git a/frontend/src/components/VehicleListStaging.jsx b/frontend/src/components/VehicleListStaging.jsx new file mode 100644 index 0000000..dcff42f --- /dev/null +++ b/frontend/src/components/VehicleListStaging.jsx @@ -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 ( + + ); +} + +/* ----- 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 ( + + {chars.map((c, i) => ( + + ))} + + ); +} + +// 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`); +} diff --git a/frontend/src/components/__tests__/NavRail.test.jsx b/frontend/src/components/__tests__/NavRail.test.jsx new file mode 100644 index 0000000..333f13c --- /dev/null +++ b/frontend/src/components/__tests__/NavRail.test.jsx @@ -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()); + }; + 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(); + } + }); +}); diff --git a/frontend/src/components/__tests__/VehicleListStaging.test.jsx b/frontend/src/components/__tests__/VehicleListStaging.test.jsx new file mode 100644 index 0000000..9f27763 --- /dev/null +++ b/frontend/src/components/__tests__/VehicleListStaging.test.jsx @@ -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(); + 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(); + 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(); + expect(anim.beginElement).not.toHaveBeenCalled(); + render(); + 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(); + expect(anim.beginElement).toHaveBeenCalledTimes(1); + }); + + it('keeps the marker static when motion is reduced', () => { + render(); + expect(container.querySelector('path')).not.toBeNull(); + expect(container.querySelector('animate')).toBeNull(); + render(); // no SMIL to start + expect(container.querySelector('animate')).toBeNull(); + }); + + it('splits the plate into hidden, staggered chars under one label', () => { + render(); + 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(); + 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;/); + }); + }); +}); diff --git a/frontend/src/components/licenseWarning.js b/frontend/src/components/licenseWarning.js new file mode 100644 index 0000000..bae85aa --- /dev/null +++ b/frontend/src/components/licenseWarning.js @@ -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 }; +} diff --git a/frontend/src/contexts/NomenclatureContext.jsx b/frontend/src/contexts/NomenclatureContext.jsx index a9db536..3ace0ec 100644 --- a/frontend/src/contexts/NomenclatureContext.jsx +++ b/frontend/src/contexts/NomenclatureContext.jsx @@ -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 diff --git a/frontend/src/hooks/__tests__/useStagedList.test.jsx b/frontend/src/hooks/__tests__/useStagedList.test.jsx new file mode 100644 index 0000000..c55520d --- /dev/null +++ b/frontend/src/hooks/__tests__/useStagedList.test.jsx @@ -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()); + 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); + }); +}); diff --git a/frontend/src/hooks/useStagedList.js b/frontend/src/hooks/useStagedList.js new file mode 100644 index 0000000..94a226b --- /dev/null +++ b/frontend/src/hooks/useStagedList.js @@ -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; +} diff --git a/frontend/src/services/__tests__/vehicleReportData.test.js b/frontend/src/services/__tests__/vehicleReportData.test.js new file mode 100644 index 0000000..e97cfa7 --- /dev/null +++ b/frontend/src/services/__tests__/vehicleReportData.test.js @@ -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); + }); +}); diff --git a/frontend/src/services/vehicleReportData.js b/frontend/src/services/vehicleReportData.js index e901b49..144e2e1 100644 --- a/frontend/src/services/vehicleReportData.js +++ b/frontend/src/services/vehicleReportData.js @@ -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, } }; diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 5d8402c..01b61d6 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -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) diff --git a/serial_bridge/requirements.txt b/serial_bridge/requirements.txt index fc7f3ae..e7a88c6 100644 --- a/serial_bridge/requirements.txt +++ b/serial_bridge/requirements.txt @@ -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