added print, download as pdf. no puppeteer, we'll see
This commit is contained in:
+10
-10
@@ -1,33 +1,33 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
color: #4CAF50;
|
||||
color: var(--success);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #f44336;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #f44336;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -40,19 +40,19 @@
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #f5c6cb;
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+32
-3
@@ -1,11 +1,13 @@
|
||||
import React, { useState, Suspense, lazy } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
// import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Login from './components/Users/Login';
|
||||
import Main from './components/Main';
|
||||
import ReportEditor from './components/ReportEditor/ReportEditor';
|
||||
import UserManager from './components/Users/UserManager';
|
||||
import History from './components/History';
|
||||
import './App.css';
|
||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||
import { NomenclatureDataProvider } from './contexts/NomenclatureDataContext';
|
||||
@@ -16,6 +18,13 @@ import LicenseManager from './components/License/LicenseManager';
|
||||
|
||||
const A4ReportEditor = lazy(() => import('./components/ReportEditor/a4/A4ReportEditor'));
|
||||
|
||||
// UI concept preview — a standalone mockup at /concept/cyberpunk, deliberately
|
||||
// OUTSIDE the auth gate and the providers. It ships its own :root variables and
|
||||
// styles body/*, which would fight the app's theme tokens if it were ever
|
||||
// rendered alongside the real UI; lazy-loading keeps its CSS out of the bundle
|
||||
// until that route is opened. Nothing in the app imports it.
|
||||
const ScalesAppCyberpunk = lazy(() => import('./components/concepts/ScalesAppCyberpunk'));
|
||||
|
||||
// function MainApp() {
|
||||
// const [selectedPort, setSelectedPort] = useState(null);
|
||||
// const { readings, isConnected, error } = useSerialData();
|
||||
@@ -69,6 +78,7 @@ function AppContent() {
|
||||
<Suspense fallback={<div className="loading"><p>Loading...</p></div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Main />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/report-editor" element={currentUser?.can_edit_documents ? <ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/report-editor-a4" element={currentUser?.can_edit_documents ? <A4ReportEditor /> : <Navigate to="/" />} />
|
||||
<Route path="/users" element={currentUser?.is_admin ? <UserManager /> : <Navigate to="/" />} />
|
||||
@@ -87,9 +97,28 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
<Routes>
|
||||
{/* Concept preview, no auth and no providers — see the note above. */}
|
||||
<Route
|
||||
path="/concept/cyberpunk"
|
||||
element={
|
||||
<Suspense fallback={<div className="loading"><p>Loading concept...</p></div>}>
|
||||
<ScalesAppCyberpunk />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
/* Outermost, so the login screen and any overlay are themed too. */
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -5,7 +5,7 @@
|
||||
.extra-combos h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
.extra-combos-error {
|
||||
padding: 6px 10px;
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
border: 1px solid #fcc;
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
@@ -25,7 +25,7 @@
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -35,17 +35,17 @@
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--surface-hover);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
background: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.extra-combos-table-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.extra-combos-table {
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
|
||||
.extra-combos-table thead {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--surface-sunken);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -64,9 +64,9 @@
|
||||
padding: 0.4rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
border-bottom: 2px solid var(--border);
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -78,9 +78,9 @@
|
||||
|
||||
.extra-combos-table td {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.extra-combos-row:hover {
|
||||
background-color: #f0f0ff;
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.extra-combos-td-actions {
|
||||
@@ -99,31 +99,31 @@
|
||||
|
||||
.extra-combos-delete-btn {
|
||||
background: none;
|
||||
border: 1px solid #dee2e6;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.extra-combos-delete-btn:hover {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
background-color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.extra-combos-stale {
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.extra-combos-empty {
|
||||
text-align: center;
|
||||
padding: 1rem !important;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
background: var(--header-bg);
|
||||
color: var(--header-text);
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--shadow-raised);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -32,10 +32,10 @@
|
||||
.nav-button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
border-radius: var(--radius);
|
||||
background: var(--header-chrome);
|
||||
border: 1px solid var(--header-chrome-border);
|
||||
color: var(--header-text);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: var(--header-chrome-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,9 @@
|
||||
left: 0;
|
||||
z-index: 101;
|
||||
min-width: 220px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -84,16 +84,16 @@
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu button + button {
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-dropdown-menu button:hover {
|
||||
background: #f2f3fa;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.nav-dropdown-menu strong {
|
||||
@@ -106,7 +106,7 @@
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -142,8 +142,8 @@
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
background: var(--header-avatar-bg);
|
||||
color: var(--header-avatar-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -151,19 +151,19 @@
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.avatar:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
background: var(--header-chrome);
|
||||
border: 1px solid var(--header-chrome-border);
|
||||
color: var(--header-text);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -172,7 +172,7 @@
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: var(--header-chrome-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -196,3 +196,28 @@
|
||||
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: 17px;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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);
|
||||
@@ -74,6 +76,13 @@ function Header() {
|
||||
📋
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="nav-button"
|
||||
onClick={() => navigate('/history')}
|
||||
title="History"
|
||||
>
|
||||
🕘
|
||||
</button>
|
||||
{currentUser?.is_admin && (
|
||||
<button
|
||||
className="nav-button"
|
||||
@@ -104,6 +113,14 @@ function Header() {
|
||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'cockpit' ? 'Switch to the classic theme' : 'Switch to the cockpit theme'}
|
||||
aria-label="Toggle colour theme"
|
||||
>
|
||||
{theme === 'cockpit' ? '☀' : '◐'}
|
||||
</button>
|
||||
<div className="user-info">
|
||||
<span className="user-name">{currentUser?.username}</span>
|
||||
<span className="user-role">({currentUser?.role})</span>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
.history {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-error {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.history-table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.history-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 32px !important;
|
||||
}
|
||||
|
||||
.history-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-badge--ok {
|
||||
color: var(--success);
|
||||
border-color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.history-badge--void {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.history-void-btn {
|
||||
padding: 4px 12px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-void-btn:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.history-void-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.history-void-inline input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.history-void-error {
|
||||
color: var(--danger);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.history-void-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-void-actions button {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.history-pager button {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-pager button:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.history-pager button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.history-pager .history-page-btn--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.history-page-gap {
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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';
|
||||
import ReportPreviewModal from './ReportPreviewModal';
|
||||
import api from '../services/api';
|
||||
import './History.css';
|
||||
|
||||
const PAGE_SIZE = 100; // matches the backend's DRF PAGE_SIZE
|
||||
|
||||
export default function History() {
|
||||
const { currentUser } = useAuth();
|
||||
const { definitions, entries } = useNomenclatureData();
|
||||
|
||||
const [vehicles, setVehicles] = useState([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
// django-filter's OrderingFilter has no explicit `ordering_fields` set on
|
||||
// VehicleViewSet, so it falls back to allowing any non-write-only
|
||||
// serializer field - net_date included - meaning ?ordering=-net_date
|
||||
// works server-side today. No client-side fallback sort is needed.
|
||||
const [previewVehicle, setPreviewVehicle] = useState(null);
|
||||
const [voidingId, setVoidingId] = useState(null);
|
||||
const [voidReason, setVoidReason] = useState('');
|
||||
const [voidError, setVoidError] = useState('');
|
||||
const [isVoiding, setIsVoiding] = useState(false);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
// Windowed page list (first, last, and a range around the current page)
|
||||
// so the pager stays usable once History accumulates hundreds of pages -
|
||||
// nothing in this feature ever deletes a vehicle, so unbounded growth is
|
||||
// the expected long-run case, not an edge case.
|
||||
const pageList = useMemo(() => {
|
||||
const spread = 2;
|
||||
const pages = new Set([1, totalPages]);
|
||||
for (let n = page - spread; n <= page + spread; n++) {
|
||||
if (n >= 1 && n <= totalPages) pages.add(n);
|
||||
}
|
||||
const sorted = [...pages].sort((a, b) => a - b);
|
||||
const withGaps = [];
|
||||
sorted.forEach((n, i) => {
|
||||
if (i > 0 && n - sorted[i - 1] > 1) withGaps.push('...');
|
||||
withGaps.push(n);
|
||||
});
|
||||
return withGaps;
|
||||
}, [page, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
api.get('/api/vehicles/', {
|
||||
params: { 'doc_number__isnull': false, ordering: '-net_date', page },
|
||||
})
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
setVehicles(res.data.results || []);
|
||||
setCount(res.data.count || 0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return;
|
||||
setError(err.response?.data?.detail || 'Failed to load history');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [page]);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const openPreview = (vehicle) => {
|
||||
setPreviewVehicle(vehicle);
|
||||
};
|
||||
|
||||
const previewData = useMemo(
|
||||
() => previewVehicle ? buildVehicleReportData(previewVehicle, definitions, entries) : null,
|
||||
[previewVehicle, definitions, entries]
|
||||
);
|
||||
|
||||
const startVoid = (vehicle) => {
|
||||
setVoidingId(vehicle.id);
|
||||
setVoidReason('');
|
||||
setVoidError('');
|
||||
};
|
||||
|
||||
const cancelVoid = () => {
|
||||
setVoidingId(null);
|
||||
setVoidReason('');
|
||||
setVoidError('');
|
||||
};
|
||||
|
||||
const confirmVoid = async () => {
|
||||
if (!voidReason.trim()) {
|
||||
setVoidError('A reason is required to void a document.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Void this document? This flags it permanently and cannot be undone casually.')) {
|
||||
return;
|
||||
}
|
||||
setIsVoiding(true);
|
||||
setVoidError('');
|
||||
try {
|
||||
const res = await api.post(`/api/vehicles/${voidingId}/void/`, { reason: voidReason.trim() });
|
||||
// History has its own separate fetch/state (not NomenclatureContext's
|
||||
// vehicles array), so the voided row is updated in place here from
|
||||
// the API response rather than relying on the SSE 'voided' handler.
|
||||
setVehicles(prev => prev.map(v => v.id === res.data.id ? res.data : v));
|
||||
cancelVoid();
|
||||
} catch (err) {
|
||||
setVoidError(err.response?.data?.detail || 'Failed to void document');
|
||||
} finally {
|
||||
setIsVoiding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<div className="history">
|
||||
<h2 className="history-title">History</h2>
|
||||
|
||||
{error && <div className="history-error">{error}</div>}
|
||||
|
||||
<div className="history-table-wrap">
|
||||
<table className="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vehicle</th>
|
||||
<th>Doc #</th>
|
||||
<th>Net (kg)</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="history-empty">Loading...</td></tr>
|
||||
) : vehicles.length === 0 ? (
|
||||
<tr><td colSpan={6} className="history-empty">No completed vehicles yet</td></tr>
|
||||
) : (
|
||||
vehicles.map(v => (
|
||||
<tr key={v.id} className="history-row" onClick={() => openPreview(v)}>
|
||||
<td>{v.vehicle_number}</td>
|
||||
<td>{v.doc_number}</td>
|
||||
<td>{v.net != null ? v.net : '—'}</td>
|
||||
<td>{formatDate(v.net_date)}</td>
|
||||
<td>
|
||||
<span className={`history-badge ${v.is_void ? 'history-badge--void' : 'history-badge--ok'}`}>
|
||||
{v.is_void ? 'Voided' : 'Completed'}
|
||||
</span>
|
||||
</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
{currentUser?.can_edit_documents && !v.is_void && (
|
||||
voidingId === v.id ? (
|
||||
<div className="history-void-inline">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason..."
|
||||
value={voidReason}
|
||||
onChange={e => setVoidReason(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{voidError && <div className="history-void-error">{voidError}</div>}
|
||||
<div className="history-void-actions">
|
||||
<button onClick={confirmVoid} disabled={isVoiding}>
|
||||
{isVoiding ? 'Voiding...' : 'Confirm'}
|
||||
</button>
|
||||
<button onClick={cancelVoid} disabled={isVoiding}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="history-void-btn" onClick={() => startVoid(v)}>
|
||||
Void
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="history-pager">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{pageList.map((n, i) => (
|
||||
n === '...' ? (
|
||||
<span key={`gap-${i}`} className="history-page-gap">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={n}
|
||||
className={n === page ? 'history-page-btn--active' : ''}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={n === page}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewData && (
|
||||
<ReportPreviewModal
|
||||
vehicleData={previewData}
|
||||
onClose={() => setPreviewVehicle(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+813
-111
File diff suppressed because it is too large
Load Diff
+277
-202
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import useSerialData from '../hooks/useSerialData';
|
||||
@@ -12,6 +12,7 @@ import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
|
||||
import ReportPreviewModal from './ReportPreviewModal';
|
||||
import ExtraCombinationsTable from './ExtraCombinationsTable';
|
||||
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
|
||||
import { buildVehicleReportData } from '../services/vehicleReportData';
|
||||
import api from '../services/api';
|
||||
import './Main.css';
|
||||
import './License/LicenseManager.css';
|
||||
@@ -27,7 +28,7 @@ export default function Main() {
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
const [newForm, setNewForm] = useState({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
const [newExtraData, setNewExtraData] = useState({});
|
||||
const [error, setError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -99,6 +100,46 @@ export default function Main() {
|
||||
const isBalanced = parsed.balanced;
|
||||
const readingError = parsed.error;
|
||||
|
||||
// Equalizer bar history: last 40 readings, oldest first, newest at the
|
||||
// right. A ticking interval, not a useEffect keyed on value changes - a
|
||||
// static weight (scale settled, or nothing on it) would otherwise never
|
||||
// push a new bar at all, since the effect only reruns when the value
|
||||
// itself changes. Sampling on a timer keeps the strip moving continuously
|
||||
// whether or not the reading is currently changing.
|
||||
const EQUALIZER_BAR_COUNT = 40;
|
||||
const EQUALIZER_SAMPLE_INTERVAL_MS = 250;
|
||||
const EQUALIZER_FIXED_MAX_KG = 60000;
|
||||
const [equalizerBars, setEqualizerBars] = useState([]);
|
||||
// 'fixed' makes bar height comparable across vehicles/sessions; 'auto'
|
||||
// scales to whatever is on screen right now, so a light load still fills
|
||||
// the card. Not persisted - always starts on 'fixed' per session.
|
||||
const [equalizerScaleMode, setEqualizerScaleMode] = useState('fixed');
|
||||
const latestReadingRef = useRef({ weight: null, balanced: null });
|
||||
latestReadingRef.current = { weight: currentWeight, balanced: isBalanced };
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const { weight, balanced } = latestReadingRef.current;
|
||||
if (weight === null || isNaN(weight)) return;
|
||||
setEqualizerBars(prev => [...prev, { value: weight, balanced }].slice(-EQUALIZER_BAR_COUNT));
|
||||
}, EQUALIZER_SAMPLE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Padded to a fixed 40 slots (empty ones render as zero-height bars).
|
||||
// 'fixed' scales against EQUALIZER_FIXED_MAX_KG (clamped at 100% above
|
||||
// it); 'auto' scales against the tallest bar currently visible, so the
|
||||
// row re-scales as the window slides instead of clipping.
|
||||
const equalizerSlots = useMemo(() => {
|
||||
const maxValue = equalizerScaleMode === 'fixed'
|
||||
? EQUALIZER_FIXED_MAX_KG
|
||||
: Math.max(1, ...equalizerBars.map(b => Math.abs(b.value)));
|
||||
const padding = Array(Math.max(0, EQUALIZER_BAR_COUNT - equalizerBars.length)).fill(null);
|
||||
return [...padding, ...equalizerBars].map(bar => ({
|
||||
balanced: bar?.balanced ?? null,
|
||||
heightPct: bar ? Math.min(100, Math.max(6, (Math.abs(bar.value) / maxValue) * 100)) : 0,
|
||||
}));
|
||||
}, [equalizerBars, equalizerScaleMode]);
|
||||
|
||||
// Tell the bridge how to talk to this scale. Polled indicators stay silent
|
||||
// until asked, so the bridge must send the command on its own timer - a
|
||||
// browser tab cannot be the heartbeat (background tabs get throttled, and a
|
||||
@@ -152,7 +193,7 @@ export default function Main() {
|
||||
// Cancel new vehicle form
|
||||
const handleCancelNew = () => {
|
||||
setShowNewForm(false);
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
setNewExtraData({});
|
||||
setError('');
|
||||
};
|
||||
@@ -168,6 +209,7 @@ export default function Main() {
|
||||
trailer1_number: newForm.trailer1_number.trim() || null,
|
||||
trailer2_number: newForm.trailer2_number.trim() || null,
|
||||
driver_pid: newForm.driver_pid.trim() || null,
|
||||
driver_name: newForm.driver_name.trim(),
|
||||
};
|
||||
// Include extra data if any values set
|
||||
const visibleNewExtra = prunedExtra(newExtraData, visibleForNew);
|
||||
@@ -178,7 +220,7 @@ export default function Main() {
|
||||
const res = await api.post('/api/vehicles/', payload);
|
||||
setSelectedVehicleId(res.data.id);
|
||||
setShowNewForm(false);
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '' });
|
||||
setNewForm({ vehicle_number: '', trailer1_number: '', trailer2_number: '', driver_pid: '', driver_name: '' });
|
||||
setNewExtraData({});
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
@@ -285,53 +327,11 @@ export default function Main() {
|
||||
const canMeasure = !!currentUser?.can_measure;
|
||||
const isFinalized = selectedVehicle?.tare != null && selectedVehicle?.gross != null;
|
||||
|
||||
// The single recorded weighing, while its tare/gross role is still undecided.
|
||||
// The backend parks a lone reading in tare, but a lighter second reading can move it to gross.
|
||||
const firstWeighing = useMemo(() => {
|
||||
if (!selectedVehicle || isFinalized) return null;
|
||||
if (selectedVehicle.tare != null) {
|
||||
return {
|
||||
value: selectedVehicle.tare,
|
||||
date: selectedVehicle.tare_date,
|
||||
userName: selectedVehicle.tare_user_name,
|
||||
manual: selectedVehicle.tare_manual,
|
||||
};
|
||||
}
|
||||
if (selectedVehicle.gross != null) {
|
||||
return {
|
||||
value: selectedVehicle.gross,
|
||||
date: selectedVehicle.gross_date,
|
||||
userName: selectedVehicle.gross_user_name,
|
||||
manual: selectedVehicle.gross_manual,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [selectedVehicle, isFinalized]);
|
||||
|
||||
// Vehicle data shaped for report preview/design
|
||||
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
|
||||
const vehicleReportData = useMemo(() => {
|
||||
if (!selectedVehicle) return null;
|
||||
const rawExtra = selectedVehicle.extra?.data || {};
|
||||
const extra = {};
|
||||
for (const [code, value] of Object.entries(rawExtra)) {
|
||||
const def = definitions[code];
|
||||
if (!def) continue;
|
||||
if (def.kind === 'lookup') {
|
||||
const entry = (entries[code] || []).find(e => e.id === value);
|
||||
extra[def.name] = entry?.display_value ?? '';
|
||||
} else {
|
||||
extra[def.name] = value ?? '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
vehicle: {
|
||||
...selectedVehicle,
|
||||
net: netWeight,
|
||||
extra,
|
||||
}
|
||||
};
|
||||
}, [selectedVehicle, netWeight, definitions, entries]);
|
||||
// Vehicle data shaped for report preview/design (shared with History)
|
||||
const vehicleReportData = useMemo(
|
||||
() => buildVehicleReportData(selectedVehicle, definitions, entries),
|
||||
[selectedVehicle, definitions, entries]
|
||||
);
|
||||
|
||||
// Look up the saved notice report by name. Server-side filter — ?name= is a
|
||||
// real filter now that django-filter is configured, replacing the old
|
||||
@@ -351,9 +351,12 @@ export default function Main() {
|
||||
import('./ReportEditor/a4/print/printReport').catch(() => {});
|
||||
};
|
||||
|
||||
// Print Notice is kind-aware: an A4 report goes straight to the browser
|
||||
// print dialog (where "Save as PDF" is a destination), a matrix report opens
|
||||
// the existing on-screen modal exactly as before.
|
||||
// Print Notice always opens the unified preview modal now — the modal
|
||||
// itself fetches both the matrix and a4 notice_report variants and decides
|
||||
// what Print/Download do based on whichever kind is selected in its own
|
||||
// switcher. Previously an a4 report skipped the modal and went straight to
|
||||
// window.print(); that bypass is gone so both kinds get the same
|
||||
// preview/print/download/mark-notice-printed flow.
|
||||
const handlePrintNotice = async () => {
|
||||
if (isPrinting) return;
|
||||
setIsPrinting(true);
|
||||
@@ -363,14 +366,7 @@ export default function Main() {
|
||||
setError('Report "notice_report" not found. Create it in the Report Editor first.');
|
||||
return;
|
||||
}
|
||||
if (report.kind === 'a4') {
|
||||
// Lazy so Main.jsx does not pull the A4 print tree into the
|
||||
// main bundle for operators who never print an A4 notice.
|
||||
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
|
||||
await printA4Report(report, vehicleReportData);
|
||||
} else {
|
||||
setShowNoticeReport(true);
|
||||
}
|
||||
setShowNoticeReport(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to print notice:', err);
|
||||
setError('Failed to load the notice report');
|
||||
@@ -487,7 +483,7 @@ export default function Main() {
|
||||
<h2 className="vehicle-detail-title">New Vehicle</h2>
|
||||
|
||||
<div className="vehicle-form-section">
|
||||
<div className="extra-fields">
|
||||
<div className="vehicle-new-fields">
|
||||
<div className="extra-field">
|
||||
<label>Vehicle Number *</label>
|
||||
<input
|
||||
@@ -525,16 +521,25 @@ export default function Main() {
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="extra-field">
|
||||
<label>Driver Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newForm.driver_name}
|
||||
onChange={e => setNewForm(prev => ({ ...prev, driver_name: e.target.value }))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<h3>Additional Data</h3>
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombinationNew}
|
||||
/>
|
||||
<h3>Additional Data</h3>
|
||||
<div className="extra-fields">
|
||||
{visibleForNew.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
@@ -561,7 +566,7 @@ export default function Main() {
|
||||
<button
|
||||
className="extra-save-btn"
|
||||
onClick={handleCreateVehicle}
|
||||
disabled={isSaving || !newForm.vehicle_number.trim()}
|
||||
disabled={isSaving || !newForm.vehicle_number.trim() || !newForm.driver_name.trim()}
|
||||
>
|
||||
{isSaving ? 'Creating...' : 'Create Vehicle'}
|
||||
</button>
|
||||
@@ -575,179 +580,249 @@ export default function Main() {
|
||||
<div className="vehicle-detail">
|
||||
{error && <div className="vehicle-error">{error}</div>}
|
||||
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
|
||||
{/* Current reading */}
|
||||
<div className="reading-display">
|
||||
<label>Current Reading</label>
|
||||
<div className={`reading-value ${
|
||||
!isConnected
|
||||
? 'reading-value--disconnected'
|
||||
: currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||
}
|
||||
{/* Header bar: icon, plate number, derived status pills, and (cockpit
|
||||
only) a photo. Both pills come from real state, not decoration:
|
||||
"on scale" is this vehicle having a live reading right now, and
|
||||
the weighing status is the same isFinalized used by the cards
|
||||
below. */}
|
||||
<div className="vehicle-hero">
|
||||
<div className="vehicle-heading">
|
||||
<span className="vehicle-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.7"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h11v11H3z" />
|
||||
<path d="M14 10h4l3 3v4h-7z" />
|
||||
<circle cx="7" cy="19" r="2" />
|
||||
<circle cx="18" cy="19" r="2" />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 className="vehicle-detail-title">{selectedVehicle.vehicle_number}</h2>
|
||||
<span className={`vehicle-status ${isFinalized ? 'vehicle-status--done' : 'vehicle-status--progress'}`}>
|
||||
● {isFinalized ? 'Finalized' : 'In progress'}
|
||||
</span>
|
||||
{isConnected && currentWeight !== null && !isNaN(currentWeight) && (
|
||||
<span className="vehicle-status vehicle-status--scale">◉ On Scale</span>
|
||||
)}
|
||||
</div>
|
||||
{isConnected && readingError && latestReading && (
|
||||
<div className="reading-error" title={latestReading.data}>
|
||||
{readingError}
|
||||
|
||||
{/* Equalizer: last 40 live readings, oldest to newest left to
|
||||
right. Green when the last reading was stable, rose when it
|
||||
wasn't. The corner button switches between scaling against a
|
||||
fixed 60t ceiling (comparable across vehicles) and scaling to
|
||||
whatever is on screen right now (fills the strip for any
|
||||
load). No border/background of its own - it sits directly on
|
||||
the hero bar between the pills and the photo. */}
|
||||
<div className="vehicle-hero-equalizer">
|
||||
<button
|
||||
type="button"
|
||||
className="equalizer-scale-toggle"
|
||||
onClick={() => setEqualizerScaleMode(m => m === 'fixed' ? 'auto' : 'fixed')}
|
||||
title={equalizerScaleMode === 'fixed'
|
||||
? 'Scaled to 60,000 kg - click for auto-scale'
|
||||
: 'Auto-scaled to visible readings - click for fixed 60,000 kg'}
|
||||
>
|
||||
{equalizerScaleMode === 'fixed' ? '60t' : 'AUTO'}
|
||||
</button>
|
||||
<div className="equalizer-bars">
|
||||
{equalizerSlots.map((slot, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`equalizer-bar ${
|
||||
slot.balanced === null ? '' : slot.balanced ? 'equalizer-bar--balanced' : 'equalizer-bar--unbalanced'
|
||||
}`}
|
||||
style={{ height: `${slot.heightPct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vehicle-hero-photo" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Weighing section */}
|
||||
{/* Weighing section - four readouts plus an action column, all of
|
||||
which always render, so the row never changes its column count
|
||||
as a weighing progresses. The readouts hold no controls: every
|
||||
button lives in the action column. */}
|
||||
<div className="weighing-section">
|
||||
{/* Before both weights exist, the first reading's role is not
|
||||
final yet - show it neutrally so it doesn't appear to jump. */}
|
||||
{selectedVehicle.tare == null || selectedVehicle.gross == null ? (
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">First weighing</div>
|
||||
<div className="weight-card-value">
|
||||
{firstWeighing ? `${firstWeighing.value} kg` : '---'}
|
||||
{/* Current reading, which doubles as the Measure card:
|
||||
both showed the same live weight. Icon and text sit in
|
||||
separate columns (weight-card-icon / -content), matching
|
||||
the reference layout - the icon spans the card's full
|
||||
height instead of sitting inline with the label. */}
|
||||
<div className="weight-card weight-card--reading">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Current Reading</div>
|
||||
<div className={`weight-card-value weight-card-value--reading ${
|
||||
!isConnected
|
||||
? 'reading-value--disconnected'
|
||||
: currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight)
|
||||
? `${currentWeight} kg`
|
||||
: (isConnected ? 'Waiting...' : 'Disconnected')
|
||||
}
|
||||
</div>
|
||||
{firstWeighing && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(firstWeighing.date)}
|
||||
{firstWeighing.userName && ` (${firstWeighing.userName})`}
|
||||
{firstWeighing.manual && <span className="weight-manual-tag">manual</span>}
|
||||
{isConnected && readingError && latestReading && (
|
||||
<div className="reading-error" title={latestReading.data}>
|
||||
{readingError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tare */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">{selectedVehicle.tare} kg</div>
|
||||
{/* Measure/Manual entry, moved here from the action column:
|
||||
they act on THIS card's live value, so they sit beside it. */}
|
||||
{canMeasure && !isFinalized && (
|
||||
<div className="weight-card-reading-actions">
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
Measure
|
||||
</button>
|
||||
{!licenseAllowed && (
|
||||
<div className="weight-license-block">{licenseDetail}</div>
|
||||
)}
|
||||
{currentUser?.can_manually_measure && (
|
||||
<button
|
||||
className="weight-manual-btn"
|
||||
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
|
||||
>
|
||||
Manual entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tare. A lone reading is parked here by the backend, but a
|
||||
lighter second one moves it to Gross - the slot is shown
|
||||
plainly rather than hedged behind a neutral label. */}
|
||||
<div className="weight-card weight-card--tare">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Tare</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.tare != null ? `${selectedVehicle.tare} kg` : '—'}
|
||||
</div>
|
||||
{selectedVehicle.tare != null && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.tare_date)}
|
||||
{selectedVehicle.tare_user_name && ` (${selectedVehicle.tare_user_name})`}
|
||||
{selectedVehicle.tare_manual && <span className="weight-manual-tag">manual</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gross */}
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">{selectedVehicle.gross} kg</div>
|
||||
{/* Gross */}
|
||||
<div className="weight-card weight-card--gross">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Gross</div>
|
||||
<div className="weight-card-value">
|
||||
{selectedVehicle.gross != null ? `${selectedVehicle.gross} kg` : '—'}
|
||||
</div>
|
||||
{selectedVehicle.gross != null && (
|
||||
<div className="weight-card-meta">
|
||||
{formatDate(selectedVehicle.gross_date)}
|
||||
{selectedVehicle.gross_user_name && ` (${selectedVehicle.gross_user_name})`}
|
||||
{selectedVehicle.gross_manual && <span className="weight-manual-tag">manual</span>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Measure */}
|
||||
{canMeasure && !isFinalized && (
|
||||
<div className="weight-card">
|
||||
<div className="weight-card-header">Scale</div>
|
||||
<div className={`weight-card-value ${
|
||||
currentWeight === null || isNaN(currentWeight)
|
||||
? ''
|
||||
: isBalanced ? 'reading-value--balanced' : 'reading-value--unbalanced'
|
||||
}`}>
|
||||
{currentWeight !== null && !isNaN(currentWeight) ? `${currentWeight} kg` : '---'}
|
||||
</div>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handleMeasure}
|
||||
disabled={currentWeight === null || isNaN(currentWeight) || !licenseAllowed}
|
||||
title={licenseAllowed ? undefined : licenseDetail}
|
||||
>
|
||||
Measure
|
||||
</button>
|
||||
{!licenseAllowed && (
|
||||
<div className="weight-license-block">{licenseDetail}</div>
|
||||
)}
|
||||
{currentUser?.can_manually_measure && (
|
||||
<button
|
||||
className="weight-manual-btn"
|
||||
onClick={() => { setManualForm({ value: '', note: '' }); setShowManualForm(true); }}
|
||||
>
|
||||
Manual entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Net */}
|
||||
{netWeight !== null && (
|
||||
<div className="weight-card weight-card--net">
|
||||
<div className="weight-card weight-card--net">
|
||||
<span className="weight-card-icon" aria-hidden="true" />
|
||||
<div className="weight-card-content">
|
||||
<div className="weight-card-header">Net</div>
|
||||
<div className="weight-card-value">{netWeight} kg</div>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handlePrintNotice}
|
||||
onMouseEnter={prefetchA4Print}
|
||||
onFocus={prefetchA4Print}
|
||||
disabled={isPrinting}
|
||||
>
|
||||
{isPrinting ? 'Preparing...' : 'Print Notice'}
|
||||
</button>
|
||||
{/* Split button: the main action routes by
|
||||
the saved report's kind; the caret opens
|
||||
an explicit choice for starting the other
|
||||
kind. */}
|
||||
<div className="design-notice-split">
|
||||
<button
|
||||
className="weight-set-btn design-notice-main"
|
||||
onClick={handleDesignNotice}
|
||||
>
|
||||
Design Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn design-notice-caret"
|
||||
onClick={() => setShowDesignMenu(v => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showDesignMenu}
|
||||
title="Choose which editor to open"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{showDesignMenu && (
|
||||
<>
|
||||
{/* Click-away closes the menu. */}
|
||||
<div
|
||||
className="design-notice-backdrop"
|
||||
onClick={() => setShowDesignMenu(false)}
|
||||
/>
|
||||
<div className="design-notice-menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('matrix')}
|
||||
>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('a4')}
|
||||
>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="weight-card-value">
|
||||
{netWeight !== null ? `${netWeight} kg` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action column. Print/Design only now - Measure/Manual entry
|
||||
moved into the Current Reading card, since they act on that
|
||||
card's live value. This column is empty (just its panel
|
||||
chrome) until weighing is finalized. */}
|
||||
<div className="weight-card weight-actions">
|
||||
{netWeight !== null && (
|
||||
<>
|
||||
<button
|
||||
className="weight-set-btn"
|
||||
onClick={handlePrintNotice}
|
||||
onMouseEnter={prefetchA4Print}
|
||||
onFocus={prefetchA4Print}
|
||||
disabled={isPrinting}
|
||||
>
|
||||
{isPrinting ? 'Preparing...' : 'Print Notice'}
|
||||
</button>
|
||||
{/* Split button: the main action routes by
|
||||
the saved report's kind; the caret opens
|
||||
an explicit choice for starting the other
|
||||
kind. */}
|
||||
<div className="design-notice-split">
|
||||
<button
|
||||
className="weight-set-btn design-notice-main"
|
||||
onClick={handleDesignNotice}
|
||||
>
|
||||
Design Notice
|
||||
</button>
|
||||
<button
|
||||
className="weight-set-btn design-notice-caret"
|
||||
onClick={() => setShowDesignMenu(v => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showDesignMenu}
|
||||
title="Choose which editor to open"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{showDesignMenu && (
|
||||
<>
|
||||
{/* Click-away closes the menu. */}
|
||||
<div
|
||||
className="design-notice-backdrop"
|
||||
onClick={() => setShowDesignMenu(false)}
|
||||
/>
|
||||
<div className="design-notice-menu" role="menu">
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('matrix')}
|
||||
>
|
||||
<strong>Matrix editor</strong>
|
||||
<span>Character grid · dot-matrix printer</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handleDesignAs('a4')}
|
||||
>
|
||||
<strong>A4 editor</strong>
|
||||
<span>Millimetres · laser printer & PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra fields from nomenclatures */}
|
||||
{vehicleNomenclatures.length > 0 && (
|
||||
<div className="extra-section">
|
||||
<h3>Additional Data</h3>
|
||||
<ExtraCombinationsTable
|
||||
nomenclatures={vehicleNomenclatures}
|
||||
onApply={handleApplyCombination}
|
||||
/>
|
||||
<h3>Additional Data</h3>
|
||||
<div className="extra-fields">
|
||||
{visibleForSelected.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from 'react';
|
||||
import { formatValue } from '../a4/formatValue';
|
||||
import { resolveDBTextValue } from '../utils/dataResolver';
|
||||
import { evaluateExpression } from '../utils/expressionEvaluator';
|
||||
import { detectCrossroads } from '../utils/crossroadDetector';
|
||||
import { calculateBandInstances } from '../utils/bandRenderer';
|
||||
import './print.css';
|
||||
|
||||
/**
|
||||
* Renders matrix (character-grid) report elements as plain positioned HTML.
|
||||
*
|
||||
* DELIBERATELY DUMB, same spirit as a4/print/PrintDocument.jsx: no hooks, no
|
||||
* context, no state, no onSelect/onUpdate/onDelete/onDragStart. Everything
|
||||
* EditorCanvas.jsx needs an interactive tree for — drag, selection, resize
|
||||
* handles, click-to-place tools — has no meaning for a printed page, so none
|
||||
* of it exists here.
|
||||
*
|
||||
* Band iteration and data resolution are NOT reimplemented: this reuses the
|
||||
* exact same pure helpers EditorCanvas.jsx's own preview-mode branches call
|
||||
* (calculateBandInstances from utils/bandRenderer.js, resolveDBTextValue from
|
||||
* utils/dataResolver.js, evaluateExpression from utils/expressionEvaluator.js,
|
||||
* detectCrossroads from utils/crossroadDetector.js) — none of those take a
|
||||
* hook or a React context, they take plain data and return plain data, which
|
||||
* is what let this component exist without borrowing EditorCanvas itself.
|
||||
* formatValue is imported from a4/formatValue.js rather than re-duplicated a
|
||||
* third time — it's already a verbatim, dependency-free copy of the same
|
||||
* function inline in DBTextField.jsx/ExpressionField.jsx (see that file's own
|
||||
* comment), and it does not import anything a4-specific.
|
||||
*
|
||||
* What IS re-derived here (not extracted, because it lives inline inside
|
||||
* EditorCanvas.jsx's render tangled up with the interactive tree, and pulling
|
||||
* it out would touch that 682-line file for matrix-print's sake alone): the
|
||||
* "standalone elements shift down below expanded bands" calculation from
|
||||
* EditorCanvas.jsx's preview-mode block. It is small (a Map keyed by band id)
|
||||
* and reads only band instance output, not any interactive state.
|
||||
*/
|
||||
|
||||
const CHAR_WIDTH = 18;
|
||||
const CHAR_HEIGHT = 32;
|
||||
|
||||
const BORDER_CHARS = {
|
||||
single: { topLeft: '┌', topRight: '┐', bottomLeft: '└', bottomRight: '┘', horizontal: '─', vertical: '│' },
|
||||
double: { topLeft: '╔', topRight: '╗', bottomLeft: '╚', bottomRight: '╝', horizontal: '═', vertical: '║' }
|
||||
};
|
||||
|
||||
const LINE_CHARS = {
|
||||
hline: { single: '─', double: '═' },
|
||||
vline: { single: '│', double: '║' }
|
||||
};
|
||||
|
||||
function frameLines(element) {
|
||||
const chars = BORDER_CHARS[element.borderStyle || 'single'];
|
||||
const { width, height } = element;
|
||||
const lines = [];
|
||||
for (let row = 0; row < height; row++) {
|
||||
let line = '';
|
||||
for (let col = 0; col < width; col++) {
|
||||
if (row === 0 && col === 0) line += chars.topLeft;
|
||||
else if (row === 0 && col === width - 1) line += chars.topRight;
|
||||
else if (row === height - 1 && col === 0) line += chars.bottomLeft;
|
||||
else if (row === height - 1 && col === width - 1) line += chars.bottomRight;
|
||||
else if (row === 0 || row === height - 1) line += chars.horizontal;
|
||||
else if (col === 0 || col === width - 1) line += chars.vertical;
|
||||
else line += ' ';
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function hlineText(element, crossroadMap) {
|
||||
const lineChar = LINE_CHARS.hline[element.lineStyle || 'single'];
|
||||
let line = '';
|
||||
for (let i = 0; i < element.length; i++) {
|
||||
const key = `${element.x + i},${element.y}`;
|
||||
line += crossroadMap[key] || lineChar;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function vlineChars(element, crossroadMap) {
|
||||
const lineChar = LINE_CHARS.vline[element.lineStyle || 'single'];
|
||||
const chars = [];
|
||||
for (let i = 0; i < element.length; i++) {
|
||||
const key = `${element.x},${element.y + i}`;
|
||||
chars.push(crossroadMap[key] || lineChar);
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** Resolve a dbtext/expr element's display string, exactly as the on-screen
|
||||
* DBTextField.jsx/ExpressionField.jsx do in previewMode=true. */
|
||||
function resolveText(element, reportData, parentBandId, bandContext) {
|
||||
if (element.type === 'dbtext') {
|
||||
const raw = resolveDBTextValue(reportData, element.objectKey, element.fieldPath, parentBandId, bandContext);
|
||||
return formatValue(raw, element.dataType || 'general');
|
||||
}
|
||||
if (element.type === 'expr') {
|
||||
const resolveField = (ref) => {
|
||||
const inner = ref.slice(1, ref.length - 1);
|
||||
const dotIndex = inner.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
return resolveDBTextValue(reportData, '', inner, parentBandId, bandContext);
|
||||
}
|
||||
const objectKey = inner.slice(0, dotIndex);
|
||||
const fieldPath = inner.slice(dotIndex + 1);
|
||||
return resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, bandContext);
|
||||
};
|
||||
const raw = evaluateExpression(element.expression, resolveField);
|
||||
return formatValue(raw, element.dataType || 'general');
|
||||
}
|
||||
// type === 'text': static content, no data resolution.
|
||||
return element.content;
|
||||
}
|
||||
|
||||
function styleFor(element, x, y) {
|
||||
return {
|
||||
left: `${x * CHAR_WIDTH}px`,
|
||||
top: `${y * CHAR_HEIGHT}px`,
|
||||
width: `${(element.width || 1) * CHAR_WIDTH}px`,
|
||||
height: `${(element.height || 1) * CHAR_HEIGHT}px`,
|
||||
textAlign: element.alignment || 'left'
|
||||
};
|
||||
}
|
||||
|
||||
/** Render one element (standalone or band child) at its already-resolved
|
||||
* grid position. `y` is absolute; `element.x`/`element.y` are used only for
|
||||
* frame/line geometry that depends on the design-time footprint. */
|
||||
function renderElement(element, x, y, reportData, parentBandId, bandContext, crossroadMap, key) {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
case 'dbtext':
|
||||
case 'expr': {
|
||||
const text = resolveText(element, reportData, parentBandId, bandContext);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`matrix-print-el matrix-print-el--${element.type}`}
|
||||
style={styleFor(element, x, y)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'frame': {
|
||||
const lines = frameLines(element);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="matrix-print-el matrix-print-el--frame"
|
||||
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px` }}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<div className="matrix-print-frame-line" key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'hline': {
|
||||
// Crossroad lookup uses DESIGN-time coordinates (element.x/y), since
|
||||
// detectCrossroads() was built from the original elements array, not
|
||||
// shifted band-instance positions. Standalone hlines have x===element.x
|
||||
// and y===element.y anyway; band-child hlines don't participate in
|
||||
// crossroad detection today (detectCrossroads runs once, pre-shift, in
|
||||
// MatrixPrintDocument below — same limitation EditorCanvas.jsx has,
|
||||
// since it too computes crossroadMap once from the raw elements list).
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="matrix-print-el matrix-print-el--hline"
|
||||
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px` }}
|
||||
>
|
||||
{hlineText(element, crossroadMap)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'vline': {
|
||||
const chars = vlineChars(element, crossroadMap);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="matrix-print-el matrix-print-el--vline"
|
||||
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px` }}
|
||||
>
|
||||
{chars.map((char, i) => (
|
||||
<div key={i} style={{ height: `${CHAR_HEIGHT}px` }}>{char}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'symbol':
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="matrix-print-el matrix-print-el--symbol"
|
||||
style={{ left: `${x * CHAR_WIDTH}px`, top: `${y * CHAR_HEIGHT}px`, width: CHAR_WIDTH, height: CHAR_HEIGHT }}
|
||||
>
|
||||
{element.char}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function MatrixPrintDocument({ elements, vehicleData }) {
|
||||
const crossroadMap = detectCrossroads(elements);
|
||||
const bands = elements.filter(el => el.type === 'band');
|
||||
const bandInstances = calculateBandInstances(bands, vehicleData, elements);
|
||||
|
||||
// Same "how far did each top-level band expand past its design footprint"
|
||||
// calculation EditorCanvas.jsx's preview-mode standalone-element branch
|
||||
// does, so non-band elements shift down to make room for repeated rows.
|
||||
const bandShifts = new Map();
|
||||
bands.filter(band => !band.parentBandId).forEach(band => {
|
||||
const bandDesignMaxY = band.y + band.height - 1;
|
||||
const instances = bandInstances.filter(inst => inst.bandId === band.id);
|
||||
if (instances.length > 0) {
|
||||
const lastInstance = instances[instances.length - 1];
|
||||
const bandPreviewMaxY = lastInstance.y + lastInstance.height - 1;
|
||||
bandShifts.set(band.id, { designMaxY: bandDesignMaxY, expansion: bandPreviewMaxY - bandDesignMaxY });
|
||||
}
|
||||
});
|
||||
|
||||
const standaloneElements = elements.filter(el => {
|
||||
if (el.type === 'band') return false;
|
||||
const isChildOfBand = elements.some(band => band.type === 'band' && band.children?.includes(el.id));
|
||||
return !isChildOfBand;
|
||||
});
|
||||
|
||||
const renderedBandChildren = bandInstances.flatMap(instance => {
|
||||
const bandData = instance.data ? { [instance.bandId]: instance.data } : {};
|
||||
return instance.childElements.map(childEl => {
|
||||
const absoluteY = instance.y + childEl.y;
|
||||
return renderElement(
|
||||
childEl,
|
||||
childEl.x,
|
||||
absoluteY,
|
||||
vehicleData,
|
||||
instance.bandId,
|
||||
{ currentBandData: bandData },
|
||||
crossroadMap,
|
||||
`${instance.bandId}-${instance.instanceIndex}-${childEl.id}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const renderedStandalone = standaloneElements.map(element => {
|
||||
let totalShift = 0;
|
||||
bandShifts.forEach(({ designMaxY, expansion }) => {
|
||||
if (element.y > designMaxY) totalShift += expansion;
|
||||
});
|
||||
return renderElement(element, element.x, element.y + totalShift, vehicleData, null, {}, crossroadMap, element.id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="print-root">
|
||||
<div className="matrix-print-page">
|
||||
{renderedBandChildren}
|
||||
{renderedStandalone}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatrixPrintDocument;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Print styles for the matrix (character-grid) designer's print/preview path.
|
||||
*
|
||||
* Mirrors a4/print/print.css in structure and in the reason it exists: a
|
||||
* genuinely non-interactive renderer (MatrixPrintDocument) needs its own
|
||||
* plain CSS, not the on-screen editor's elements.css classes, which carry
|
||||
* cursor/hover/selected/dragging states that have no meaning once there is
|
||||
* no interaction to style.
|
||||
*
|
||||
* Geometry is character-grid pixels (CHAR_WIDTH=18, CHAR_HEIGHT=32), NOT
|
||||
* millimetres — matrix reports are a fixed character grid, printed at
|
||||
* whatever physical size the browser's print scaling produces, same as the
|
||||
* on-screen editor already assumes (EditorCanvas.jsx's own CHAR_WIDTH/
|
||||
* CHAR_HEIGHT constants).
|
||||
*
|
||||
* `.print-root` here is intentionally the SAME class name a4/print/print.css
|
||||
* defines (`display:none` on screen, `display:block!important` under
|
||||
* `@media print`, with `body > *:not(.print-root)` hiding everything else).
|
||||
* Both matrix and A4 print flows append their container directly to <body>
|
||||
* with this class, and only one print can be in flight at a time, so sharing
|
||||
* the rule is correct, not a collision — redefining it here would be
|
||||
* redundant, not wrong, but ReportPreviewModal.jsx already transitively loads
|
||||
* a4/print/print.css (via its PrintDocument import), so this file does not
|
||||
* repeat that rule.
|
||||
*/
|
||||
|
||||
.matrix-print-page {
|
||||
position: relative;
|
||||
width: 1440px; /* 80 cols * 18px, matches EditorCanvas GRID_COLS/CHAR_WIDTH */
|
||||
background: #fff;
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 32px;
|
||||
line-height: 32px;
|
||||
white-space: pre;
|
||||
letter-spacing: -1.2px;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.matrix-print-el {
|
||||
position: absolute;
|
||||
box-sizing: content-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.matrix-print-el--dbtext,
|
||||
.matrix-print-el--expr,
|
||||
.matrix-print-el--text {
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.matrix-print-frame-line {
|
||||
white-space: pre;
|
||||
line-height: 32px;
|
||||
}
|
||||
@@ -9,54 +9,163 @@
|
||||
}
|
||||
|
||||
.report-preview-modal {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 95vw;
|
||||
width: 90vw;
|
||||
max-width: 1000px;
|
||||
max-height: 95vh;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
|
||||
.report-preview-header {
|
||||
.report-preview-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.report-preview-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
color: #555;
|
||||
.report-preview-switcher {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.report-preview-close:hover {
|
||||
color: #000;
|
||||
.report-preview-switch-btn {
|
||||
padding: 6px 14px;
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-preview-switch-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.report-preview-switch-btn--active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.report-preview-switch-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.report-preview-download-btn {
|
||||
padding: 6px 14px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-preview-download-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.report-preview-download-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.report-preview-body {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.report-preview-error {
|
||||
padding: 24px;
|
||||
color: #c00;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-preview-loading {
|
||||
padding: 24px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-preview-empty {
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.report-preview-design-btn {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A4 body: PrintDocument renders true millimetres, so it is scaled down to
|
||||
fit the modal rather than shown at physical size. transform-origin keeps
|
||||
it anchored top-left; the wrapper's own height is set to match the scaled
|
||||
result so the surrounding flex layout doesn't leave a tall empty gap. */
|
||||
.report-preview-a4-scale {
|
||||
transform: scale(0.6);
|
||||
transform-origin: top left;
|
||||
width: 210mm;
|
||||
}
|
||||
|
||||
.report-preview-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.report-preview-print-btn {
|
||||
padding: 8px 24px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-preview-print-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.report-preview-close-btn {
|
||||
padding: 8px 24px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-preview-close-btn:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { DataProvider, useReportData } from './ReportEditor/DataContext';
|
||||
import { BandProvider } from './ReportEditor/BandContext';
|
||||
import EditorCanvas from './ReportEditor/EditorCanvas';
|
||||
import PrintDocument from './ReportEditor/a4/print/PrintDocument';
|
||||
import MatrixPrintDocument from './ReportEditor/print/MatrixPrintDocument';
|
||||
import { layout } from './ReportEditor/a4/layout';
|
||||
import api from '../services/api';
|
||||
import './ReportPreviewModal.css';
|
||||
|
||||
function PreviewContent({ vehicleData, elements }) {
|
||||
/**
|
||||
* Renders the matrix report via the existing EditorCanvas preview machinery.
|
||||
* Reused both for the modal body and (via printMatrixReport below) inside the
|
||||
* print-root container, so the EditorCanvas wiring exists in one place.
|
||||
*/
|
||||
function MatrixPreview({ vehicleData, elements }) {
|
||||
const { setData } = useReportData();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,42 +42,225 @@ function PreviewContent({ vehicleData, elements }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the matrix report using the same print-root-append pattern
|
||||
* printA4Report established: a container carrying the `print-root` class is
|
||||
* appended directly to <body> (so print.css's `body > *:not(.print-root)`
|
||||
* hiding rule matches it), rendered synchronously via flushSync so
|
||||
* window.print() never sees an empty container, then torn down on
|
||||
* afterprint (or a fallback timeout, since afterprint is not guaranteed to
|
||||
* fire).
|
||||
*
|
||||
* Renders via MatrixPrintDocument (a pure, non-interactive renderer, mirroring
|
||||
* a4/print/PrintDocument.jsx), NOT the MatrixPreview/EditorCanvas machinery
|
||||
* used for the on-screen body above — the print output should not carry any
|
||||
* editor-only affordances (selection, drag, resize handles), and a plain
|
||||
* function component needs no DataProvider/BandProvider context to run.
|
||||
*/
|
||||
function printMatrixReport(elements, vehicleData) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'print-root';
|
||||
document.body.appendChild(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
flushSync(() => {
|
||||
root.render(<MatrixPrintDocument elements={elements} vehicleData={vehicleData} />);
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
let done = false;
|
||||
let fallbackTimer = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
window.removeEventListener('afterprint', cleanup);
|
||||
clearTimeout(fallbackTimer);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
root.unmount();
|
||||
} catch (err) {
|
||||
console.error('Matrix print teardown failed to unmount cleanly:', err);
|
||||
}
|
||||
container.remove();
|
||||
resolve();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
window.addEventListener('afterprint', cleanup);
|
||||
fallbackTimer = setTimeout(cleanup, 60000);
|
||||
|
||||
try {
|
||||
window.print();
|
||||
} catch (err) {
|
||||
console.error('Print failed:', err);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function ReportPreviewModal({ vehicleData, onClose }) {
|
||||
const [elements, setElements] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const vehicleId = vehicleData?.vehicle?.id;
|
||||
|
||||
// Both kind-variants of notice_report, fetched together so the switcher
|
||||
// can toggle without a re-fetch. null = not loaded yet, undefined entry =
|
||||
// that kind doesn't exist.
|
||||
const [reports, setReports] = useState(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [activeKind, setActiveKind] = useState(
|
||||
() => localStorage.getItem('noticeReportLastKind') || 'matrix'
|
||||
);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/api/reports/')
|
||||
api.get('/api/reports/?name=notice_report')
|
||||
.then(res => {
|
||||
const reports = res.data.results || res.data;
|
||||
const report = reports.find(r => r.name === 'notice_report');
|
||||
if (report) {
|
||||
setElements(report.elements || []);
|
||||
} else {
|
||||
setError('Report "notice_report" not found. Create it in the Report Editor first.');
|
||||
const list = res.data.results || res.data || [];
|
||||
const byKind = { matrix: null, a4: null };
|
||||
for (const r of list) {
|
||||
if (r.kind === 'a4') byKind.a4 = r;
|
||||
else byKind.matrix = r;
|
||||
}
|
||||
setReports(byKind);
|
||||
// Default to the last-used kind if it exists, else whichever
|
||||
// kind is actually saved.
|
||||
setActiveKind(prev => {
|
||||
if (byKind[prev]) return prev;
|
||||
return byKind.matrix ? 'matrix' : byKind.a4 ? 'a4' : prev;
|
||||
});
|
||||
if (!byKind.matrix && !byKind.a4) {
|
||||
setLoadError('Report "notice_report" not found. Create it in the Report Editor first.');
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Failed to load report'));
|
||||
.catch(() => setLoadError('Failed to load report'));
|
||||
}, []);
|
||||
|
||||
const activeReport = reports ? reports[activeKind] : null;
|
||||
|
||||
// A4 layout is pure and cheap to recompute on kind/data change.
|
||||
const a4Pages = useMemo(() => {
|
||||
if (activeKind !== 'a4' || !activeReport) return null;
|
||||
const pageSetup = activeReport.pageSetup || activeReport.page_setup || {};
|
||||
return layout(activeReport.elements, vehicleData, pageSetup).pages;
|
||||
}, [activeKind, activeReport, vehicleData]);
|
||||
|
||||
const switchKind = (kind) => {
|
||||
setActiveKind(kind);
|
||||
localStorage.setItem('noticeReportLastKind', kind);
|
||||
};
|
||||
|
||||
const goDesign = (kind) => {
|
||||
const route = kind === 'a4' ? '/report-editor-a4' : '/report-editor';
|
||||
navigate(route, { state: { reportName: 'notice_report', vehicleData } });
|
||||
};
|
||||
|
||||
const markNoticePrinted = async () => {
|
||||
if (!vehicleId) return;
|
||||
try {
|
||||
await api.post(`/api/vehicles/${vehicleId}/mark-notice-printed/`);
|
||||
} catch (err) {
|
||||
console.error('Failed to mark notice as printed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Triggered right when Print/Download PDF is clicked, not gated on the
|
||||
// print flow's own completion - a physically failed print (paper jam,
|
||||
// offline printer) must still move the vehicle to History, where it's
|
||||
// one click to reprint, rather than stranding it silently in the active
|
||||
// list.
|
||||
//
|
||||
// Print and Download PDF are the SAME flow: window.print() opens the
|
||||
// browser's print dialog either way, and "Save as PDF" is simply the
|
||||
// destination the operator picks there - see the finalize-history-void
|
||||
// plan's Phase D decision (no server-side rendering, no PDF library, so
|
||||
// the output always uses the station's own installed fonts). One shared
|
||||
// handler avoids two call sites drifting apart.
|
||||
const handlePrintOrDownload = async () => {
|
||||
if (isPrinting) return;
|
||||
setIsPrinting(true);
|
||||
try {
|
||||
if (activeKind === 'a4') {
|
||||
const { printA4Report } = await import('./ReportEditor/a4/print/printReport');
|
||||
await printA4Report(activeReport, vehicleData);
|
||||
} else {
|
||||
await printMatrixReport(activeReport.elements || [], vehicleData);
|
||||
}
|
||||
await markNoticePrinted();
|
||||
} catch (err) {
|
||||
console.error('Print failed:', err);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const kindLabel = { matrix: 'Matrix', a4: 'A4' };
|
||||
|
||||
return (
|
||||
<div className="report-preview-overlay" onClick={onClose}>
|
||||
<div className="report-preview-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="report-preview-header">
|
||||
<span>Notice Report — {vehicleData.vehicle.vehicle_number}</span>
|
||||
<button className="report-preview-close" onClick={onClose}>×</button>
|
||||
<div className="report-preview-topbar">
|
||||
<div className="report-preview-switcher" role="tablist">
|
||||
{['matrix', 'a4'].map(kind => (
|
||||
<button
|
||||
key={kind}
|
||||
role="tab"
|
||||
aria-selected={activeKind === kind}
|
||||
className={`report-preview-switch-btn ${activeKind === kind ? 'report-preview-switch-btn--active' : ''}`}
|
||||
onClick={() => switchKind(kind)}
|
||||
disabled={!reports?.[kind]}
|
||||
title={reports?.[kind] ? undefined : `No ${kindLabel[kind]} version designed yet`}
|
||||
>
|
||||
{kindLabel[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="report-preview-download-btn"
|
||||
onClick={handlePrintOrDownload}
|
||||
disabled={!activeReport || isPrinting}
|
||||
title="Opens the print dialog - choose 'Save as PDF' to download"
|
||||
>
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="report-preview-body">
|
||||
{error ? (
|
||||
<div className="report-preview-error">{error}</div>
|
||||
) : elements === null ? (
|
||||
{loadError ? (
|
||||
<div className="report-preview-error">{loadError}</div>
|
||||
) : reports === null ? (
|
||||
<div className="report-preview-loading">Loading...</div>
|
||||
) : !activeReport ? (
|
||||
<div className="report-preview-empty">
|
||||
<p>No {kindLabel[activeKind]} version of "notice_report" has been designed yet.</p>
|
||||
<button className="report-preview-design-btn" onClick={() => goDesign(activeKind)}>
|
||||
Design {kindLabel[activeKind]} Notice
|
||||
</button>
|
||||
</div>
|
||||
) : activeKind === 'a4' ? (
|
||||
<div className="report-preview-a4-scale">
|
||||
<PrintDocument pages={a4Pages || []} pageSetup={activeReport.pageSetup || activeReport.page_setup || {}} />
|
||||
</div>
|
||||
) : (
|
||||
<DataProvider>
|
||||
<PreviewContent vehicleData={vehicleData} elements={elements} />
|
||||
<MatrixPreview vehicleData={vehicleData} elements={activeReport.elements || []} />
|
||||
</DataProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="report-preview-footer">
|
||||
<button
|
||||
className="report-preview-print-btn"
|
||||
onClick={handlePrintOrDownload}
|
||||
disabled={!activeReport || isPrinting}
|
||||
>
|
||||
{isPrinting ? 'Printing...' : 'Print'}
|
||||
</button>
|
||||
<button className="report-preview-close-btn" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.scale-monitor {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f7;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.sm-body {
|
||||
@@ -19,17 +19,17 @@
|
||||
|
||||
.sm-back {
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sm-back:hover {
|
||||
border-color: #667eea;
|
||||
color: #667eea;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sm-conn {
|
||||
@@ -42,27 +42,27 @@
|
||||
}
|
||||
|
||||
.sm-conn--on {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
background-color: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.sm-conn--off {
|
||||
background-color: #fee;
|
||||
color: #c62828;
|
||||
background-color: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sm-status {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.sm-message {
|
||||
padding: 8px 12px;
|
||||
background-color: #eef0ff;
|
||||
border: 1px solid #ccd;
|
||||
background-color: var(--accent-soft);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
color: #445;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -80,10 +80,10 @@
|
||||
}
|
||||
|
||||
.sm-panel {
|
||||
background: white;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 3px var(--shadow-card);
|
||||
}
|
||||
|
||||
.sm-panel--wide {
|
||||
@@ -101,7 +101,7 @@
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -114,16 +114,16 @@
|
||||
.sm-panel-actions button {
|
||||
padding: 4px 10px;
|
||||
background: none;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sm-panel-actions button:hover {
|
||||
border-color: #667eea;
|
||||
color: #667eea;
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sm-send {
|
||||
@@ -135,7 +135,7 @@
|
||||
.sm-send input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
@@ -143,10 +143,10 @@
|
||||
|
||||
.sm-send button {
|
||||
padding: 6px 14px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent));
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
color: var(--accent-contrast);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -159,32 +159,32 @@
|
||||
.sm-lines {
|
||||
height: 420px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background-color: #fafafa;
|
||||
background-color: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.sm-line {
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sm-line:hover {
|
||||
background-color: #f0f0ff;
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sm-line--selected {
|
||||
background-color: #e8eaff;
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sm-line-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sm-visible-space {
|
||||
@@ -193,12 +193,12 @@
|
||||
}
|
||||
|
||||
.sm-len {
|
||||
color: #aaa;
|
||||
color: var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sm-line-hex {
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
margin-top: 2px;
|
||||
@@ -207,7 +207,7 @@
|
||||
.sm-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -230,7 +230,7 @@
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -238,13 +238,13 @@
|
||||
.sm-form input[type="text"],
|
||||
.sm-form input[type="number"] {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sm-check {
|
||||
@@ -254,27 +254,27 @@
|
||||
text-transform: none !important;
|
||||
letter-spacing: normal !important;
|
||||
font-weight: 400 !important;
|
||||
color: #555 !important;
|
||||
color: var(--text) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.sm-hint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sm-preview {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sm-preview h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -289,23 +289,23 @@
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sm-bal {
|
||||
color: #2e7d32;
|
||||
color: var(--success);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sm-unbal {
|
||||
color: #c62828;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sm-preview-error {
|
||||
color: #c62828;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -313,10 +313,10 @@
|
||||
.sm-save {
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent));
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
color: var(--accent-contrast);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -324,7 +324,7 @@
|
||||
|
||||
.sm-save:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4);
|
||||
box-shadow: 0 3px 8px var(--accent-soft);
|
||||
}
|
||||
|
||||
.sm-protocols {
|
||||
@@ -338,25 +338,25 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #eee;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sm-protocol:hover {
|
||||
background-color: #f0f0ff;
|
||||
background-color: var(--accent-soft);
|
||||
}
|
||||
|
||||
.sm-protocol-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sm-protocol-kind,
|
||||
.sm-protocol-poll {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700&family=Rajdhani:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root{
|
||||
--bg:#02070c;--surface:#06131d;--surface2:#081b28;--surface3:#0a2230;
|
||||
--cyan:#12dcff;--cyan2:#007fa8;--blue:#087dff;--green:#00f39c;
|
||||
--orange:#ff6426;--text:#e4f9ff;--muted:#638b9d;--dim:#345a6b;
|
||||
--line:#0c526a;--line2:#123749;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:#02070c;color:var(--text);font-family:Rajdhani,"Segoe UI",sans-serif}
|
||||
button,input{font:inherit}
|
||||
button{cursor:pointer}
|
||||
.app{
|
||||
min-height:100vh;display:grid;grid-template-columns:252px 1fr;grid-template-rows:78px 1fr;
|
||||
background:
|
||||
radial-gradient(ellipse at 68% 9%,rgba(0,180,255,.11),transparent 28%),
|
||||
radial-gradient(ellipse at 80% 80%,rgba(0,60,120,.12),transparent 32%),
|
||||
linear-gradient(120deg,#02070c,#03111b 48%,#02070c);
|
||||
overflow:hidden;
|
||||
}
|
||||
.app:before{
|
||||
content:"";position:fixed;inset:0;pointer-events:none;z-index:20;opacity:.045;
|
||||
background:repeating-linear-gradient(0deg,transparent 0 3px,#fff 4px);
|
||||
mix-blend-mode:screen;
|
||||
}
|
||||
|
||||
/* TOP */
|
||||
.topbar{
|
||||
grid-column:1/-1;height:78px;display:flex;align-items:center;padding:0 25px;
|
||||
border-bottom:1px solid #0b4052;background:rgba(3,12,19,.94);backdrop-filter:blur(14px);
|
||||
box-shadow:0 10px 45px rgba(0,0,0,.35);position:relative;z-index:10;
|
||||
}
|
||||
.topbar:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);box-shadow:0 0 12px var(--cyan);opacity:.45}
|
||||
.brand{width:248px;display:flex;align-items:center;gap:12px}
|
||||
.brand-emblem{width:43px;height:43px;display:grid;place-items:center;color:var(--cyan);border:1px solid #08718a;background:linear-gradient(145deg,rgba(0,221,255,.13),rgba(0,0,0,.15));border-radius:9px;box-shadow:0 0 25px rgba(0,217,255,.14),inset 0 0 18px rgba(0,217,255,.08)}
|
||||
.brand-name{font-weight:700;font-size:20px;letter-spacing:.08em;line-height:1}
|
||||
.brand-caption{font-size:8px;color:var(--cyan);letter-spacing:.17em;margin-top:5px}
|
||||
.main-nav{display:flex;gap:8px}
|
||||
.top-nav{width:65px;height:50px;color:#5d8596;border:1px solid #133e50;background:linear-gradient(180deg,#071923,#04111a);border-radius:8px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;box-shadow:inset 0 0 15px rgba(0,150,200,.025);transition:.2s}
|
||||
.top-nav span{font-size:7px;font-weight:700;letter-spacing:.1em}
|
||||
.top-nav.active,.top-nav:hover{color:var(--cyan);border-color:var(--cyan);background:linear-gradient(180deg,#082633,#04141e);box-shadow:0 0 22px rgba(0,217,255,.17),inset 0 0 20px rgba(0,217,255,.09)}
|
||||
.system-status{margin-left:auto;display:flex;gap:9px;padding-right:21px;border-right:1px solid #163b4b;align-items:flex-start}
|
||||
.system-status strong{display:block;color:var(--cyan);font-size:9px;letter-spacing:.1em}
|
||||
.system-status small{display:block;color:#5c8292;font-size:8px}
|
||||
.pulse{width:7px;height:7px;margin-top:3px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px rgba(0,243,156,.06),0 0 13px var(--green);animation:pulse 1.8s infinite}
|
||||
@keyframes pulse{50%{box-shadow:0 0 0 8px rgba(0,243,156,0),0 0 20px var(--green)}}
|
||||
.user{display:flex;align-items:center;gap:9px;margin-left:20px}
|
||||
.avatar{width:35px;height:35px;border-radius:50%;display:grid;place-items:center;color:#001018;font-weight:700;background:linear-gradient(135deg,#48ecff,#087fff);box-shadow:0 0 22px rgba(0,180,255,.25)}
|
||||
.user b{font-size:10px;display:block}.user small{font-size:8px;color:#638999;display:block}
|
||||
.user button{margin-left:11px;background:rgba(5,19,28,.8);border:1px solid #255266;color:#86aab7;padding:7px 12px;font-size:9px;border-radius:5px}
|
||||
|
||||
/* SIDEBAR */
|
||||
.sidebar{position:relative;padding:20px 16px;border-right:1px solid #0a4053;background:linear-gradient(180deg,rgba(5,19,29,.97),rgba(2,9,14,.98));overflow:hidden}
|
||||
.sidebar:after{content:"";position:absolute;right:-45px;top:220px;width:110px;height:340px;background:repeating-linear-gradient(90deg,transparent 0 9px,#0a2b39 10px 11px);opacity:.18;transform:skewY(-25deg)}
|
||||
.new-vehicle{width:100%;height:42px;border:1px solid #ff8658;border-radius:6px;color:#160800;font-weight:800;letter-spacing:.08em;background:linear-gradient(180deg,#ff7b35,#ff5821);box-shadow:0 0 22px rgba(255,96,30,.22),inset 0 1px rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;gap:7px}
|
||||
.search{height:36px;margin:12px 0;display:flex;align-items:center;gap:8px;padding:0 10px;border:1px solid #123e50;border-radius:6px;background:#03101a;box-shadow:inset 0 0 14px rgba(0,0,0,.4)}
|
||||
.search input{width:100%;border:0;outline:0;background:none;color:#c5e5ee;font-size:10px}.search input::placeholder{color:#426d7e}
|
||||
.vehicle-list{display:grid;gap:3px}
|
||||
.vehicle{height:45px;width:100%;border:1px solid transparent;border-left:2px solid transparent;border-radius:5px;background:transparent;color:#7093a1;display:flex;align-items:center;gap:7px;padding:0 9px;text-align:left;position:relative}
|
||||
.vehicle:hover{color:#bcecf6;background:rgba(0,190,240,.035)}
|
||||
.vehicle.selected{color:var(--cyan);border-color:#09647c;border-left-color:var(--cyan);background:linear-gradient(90deg,rgba(0,217,255,.13),rgba(0,217,255,.015));box-shadow:0 0 20px rgba(0,217,255,.06),inset 0 0 20px rgba(0,217,255,.05)}
|
||||
.vehicle-dot{width:5px;height:5px;border-radius:50%;background:#2a4652}.vehicle-dot.live{background:var(--green);box-shadow:0 0 8px var(--green)}
|
||||
.vehicle b{font-size:11px}.vehicle small{margin-left:auto;color:#4e8aa0;font-size:9px}
|
||||
.sidebar-label{position:absolute;bottom:20px;left:18px;color:#4d7a8b;font-size:8px;letter-spacing:.18em;display:flex;gap:8px;align-items:center}.sidebar-label span{color:var(--cyan);font-size:15px}
|
||||
|
||||
/* CONTENT */
|
||||
.content{min-width:0;min-height:0;padding:18px 24px 12px;overflow:auto;background:radial-gradient(ellipse at 70% 0,rgba(0,170,255,.07),transparent 27%)}
|
||||
.vehicle-hero{height:106px;border:1px solid #0a5167;border-radius:10px;position:relative;overflow:hidden;background:linear-gradient(90deg,#06131d 0,#06131d 42%,rgba(6,19,29,.7) 65%,#06131d);box-shadow:0 15px 35px rgba(0,0,0,.18),inset 0 0 35px rgba(0,150,210,.035)}
|
||||
.vehicle-hero:after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,rgba(0,0,0,.28),transparent 40%,rgba(0,0,0,.25))}
|
||||
.vehicle-heading{position:relative;z-index:4;height:100%;display:flex;align-items:center;padding-left:17px;gap:10px}
|
||||
.vehicle-icon{width:52px;height:52px;display:grid;place-items:center;color:var(--cyan);border:1px solid #0b728a;border-radius:9px;background:rgba(0,217,255,.07);box-shadow:0 0 25px rgba(0,217,255,.1),inset 0 0 15px rgba(0,217,255,.07)}
|
||||
.vehicle-heading h1{margin:0;font:700 31px "Barlow Condensed",sans-serif;letter-spacing:.04em}.crumb{font-size:8px;color:#527b8c;letter-spacing:.13em;margin-top:2px}.crumb i{color:var(--cyan);font-style:normal;margin:0 5px}
|
||||
.status{font-size:8px;padding:5px 8px;border:1px solid;border-radius:20px;letter-spacing:.08em}.status.active{color:var(--green);border-color:#08765a;background:rgba(0,243,156,.05);box-shadow:0 0 12px rgba(0,243,156,.06)}.status.scale{color:#ffad59;border-color:#76502d;background:rgba(255,100,38,.05)}
|
||||
|
||||
/* HERO ART */
|
||||
.industrial-hero{position:absolute;inset:0 0 0 39%;overflow:hidden}
|
||||
/* The photograph, cropped to the hero strip. `62% 42%` holds the truck cab in
|
||||
frame as the panel width changes; `center` drifts to the empty loading bay. */
|
||||
.hero-photo{position:absolute;inset:0;background:url('../../assets/weigh-station-hero.jpg') right center/cover no-repeat;
|
||||
/* Pushed toward the panel's palette so the photo belongs to the UI rather
|
||||
than sitting in it: slightly desaturated, cooled, and darkened at source
|
||||
so the overlays below read as light rather than fighting the image. */
|
||||
filter:saturate(1.02) contrast(1.08) brightness(.88) hue-rotate(-4deg)}
|
||||
/* Fades the photo out to the left so it slides under the vehicle title instead
|
||||
of ending on a hard vertical seam. */
|
||||
.hero-photo:after{content:"";position:absolute;inset:0;
|
||||
background:linear-gradient(90deg,#061019 0%,rgba(6,16,25,.82) 12%,rgba(6,16,25,.28) 34%,transparent 58%)}
|
||||
.hero-vignette{position:absolute;inset:0;background:linear-gradient(90deg,#06131d 0%,rgba(5,17,26,.58) 17%,rgba(5,17,26,.05) 62%,rgba(2,8,12,.25));z-index:3}
|
||||
.hero-scan{position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(0,217,255,.07),transparent);transform:translateX(-100%);animation:scan 7s linear infinite;z-index:4}@keyframes scan{to{transform:translateX(180%)}}
|
||||
.hero-haze{position:absolute;border-radius:50%;filter:blur(28px);opacity:.35}.haze-a{width:250px;height:90px;right:80px;top:15px;background:#087bff}.haze-b{width:180px;height:80px;right:300px;bottom:0;background:#ff5b24;opacity:.15}
|
||||
.hero-neon{position:absolute;height:2px;background:var(--cyan);box-shadow:0 0 9px var(--cyan),0 0 22px rgba(0,217,255,.45)}.neon-a{right:210px;top:23px;width:125px}.neon-b{right:52px;top:43px;width:80px;background:var(--orange);box-shadow:0 0 10px var(--orange)}.neon-c{right:360px;bottom:28px;width:90px}
|
||||
|
||||
/* METRICS */
|
||||
.metric-row{display:grid;grid-template-columns:1.28fr 1fr 1fr 1fr 1.05fr;gap:10px;margin-top:12px}
|
||||
.metric-card{height:99px;position:relative;overflow:hidden;border:1px solid #10536a;border-radius:9px;background:linear-gradient(145deg,#071a25,#04131d);padding:12px 14px;box-shadow:0 10px 24px rgba(0,0,0,.16),inset 0 0 22px rgba(0,150,200,.025)}
|
||||
.metric-card:before{content:"";position:absolute;left:0;top:0;width:34%;height:1px;background:#1e839b;box-shadow:0 0 10px #1e839b}
|
||||
.metric-card.current{border-color:#08b9dc;box-shadow:0 0 25px rgba(0,217,255,.09),inset 0 0 25px rgba(0,217,255,.07)}.metric-card.current:before{width:65%;background:var(--cyan);box-shadow:0 0 13px var(--cyan)}
|
||||
.metric-card.net{border-color:#087c62;background:linear-gradient(145deg,#061d1b,#041411)}.metric-card.net:before{background:var(--green);box-shadow:0 0 10px var(--green)}
|
||||
.metric-glow{position:absolute;width:110px;height:110px;right:-45px;bottom:-55px;border-radius:50%;border:1px solid rgba(0,217,255,.08);box-shadow:0 0 30px rgba(0,217,255,.03)}
|
||||
.metric-label{display:flex;align-items:center;gap:7px;color:#7298a7;font-size:8px;font-weight:600;letter-spacing:.12em}.metric-icon{width:25px;height:25px;border:1px solid #17647a;border-radius:6px;display:grid;place-items:center;color:var(--cyan);background:rgba(0,217,255,.055)}.net .metric-icon{color:var(--green);border-color:#0b705a}
|
||||
.metric-value{font:700 25px "Barlow Condensed",sans-serif;letter-spacing:.035em;margin-top:4px;color:#e8fbff}.metric-card.big .metric-value{font-size:34px;color:var(--cyan);text-shadow:0 0 17px rgba(0,217,255,.25)}.metric-card.net .metric-value{color:var(--green);text-shadow:0 0 15px rgba(0,243,156,.15)}
|
||||
.metric-time{font-size:7px;color:#4d7484;margin-top:1px}.metric-time b{color:#668d9b}
|
||||
.action-stack{display:flex;flex-direction:column;gap:8px}.print,.design{height:45px;border-radius:7px;display:flex;align-items:center;gap:8px;padding:0 14px;font-weight:700;font-size:10px;letter-spacing:.06em}.print{color:#160700;background:linear-gradient(180deg,#ff7132,#ff5820);border:1px solid #ff8a5d;box-shadow:0 0 18px rgba(255,95,30,.16)}.design{color:var(--cyan);background:#061923;border:1px solid #14627a;box-shadow:inset 0 0 15px rgba(0,217,255,.04)}.design svg:last-child{margin-left:auto}
|
||||
|
||||
/* DATA */
|
||||
.data-card{margin-top:12px;border:1px solid #0c546a;border-radius:10px;background:linear-gradient(180deg,rgba(5,21,31,.98),rgba(3,13,20,.98));box-shadow:0 20px 45px rgba(0,0,0,.18),inset 0 0 35px rgba(0,150,210,.025);overflow:hidden;position:relative}
|
||||
.data-card:before{content:"";position:absolute;left:0;top:0;width:190px;height:2px;background:var(--cyan);box-shadow:0 0 12px var(--cyan)}
|
||||
.card-header{height:49px;padding:0 17px;border-bottom:1px solid #0d3c4d;display:flex;align-items:center;justify-content:space-between}.card-header>div{display:flex;align-items:center;gap:9px;color:var(--cyan)}.card-header b{font-size:13px;color:#bdeaf3}.card-header>span{font-size:7px;color:#315f70;letter-spacing:.2em}
|
||||
.history-header{height:38px;padding:0 17px;display:flex;align-items:center;justify-content:space-between}.history-header div{display:flex;gap:7px;align-items:center;color:var(--cyan);font-size:8px;font-weight:600;letter-spacing:.14em}.history-header button{border:0;background:none;color:#4e8394;font-size:8px;letter-spacing:.12em}.history-header button span{color:var(--cyan)}
|
||||
.table-shell{margin:0 17px;border:1px solid #0d3d4d;border-radius:5px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;min-width:760px}th{text-align:left;padding:8px;color:#4e8394;font-size:7px;letter-spacing:.1em;background:linear-gradient(180deg,#071d29,#061722);border-right:1px solid #0d3544}td{padding:8px;color:#8bb1bd;font-size:9px;border-top:1px solid #0c3040;border-right:1px solid #0b2d3b;background:rgba(2,12,18,.65)}tbody tr:hover td{background:rgba(0,217,255,.045);color:#c8eff6}.remove{width:20px;height:20px;border:1px solid #275565;background:#05131b;color:#7197a4;border-radius:4px}
|
||||
.form{padding:15px 17px 5px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px 15px}.field>span{display:block;color:#568597;font-size:7px;letter-spacing:.13em;margin-bottom:5px}.control{height:35px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;border:1px solid #174c5f;border-radius:5px;background:linear-gradient(180deg,#061721,#031018);color:#a2c5ce;font-size:9px;box-shadow:inset 0 0 12px rgba(0,0,0,.3)}.control:hover{border-color:#1491ae;box-shadow:0 0 12px rgba(0,217,255,.05),inset 0 0 12px rgba(0,0,0,.3)}
|
||||
.bottom-actions{display:flex;align-items:center;gap:15px;padding:10px 17px 15px}.save{height:38px;padding:0 16px;display:flex;align-items:center;gap:8px;color:#001219;font-weight:800;font-size:9px;letter-spacing:.05em;border:1px solid #6af0ff;border-radius:5px;background:linear-gradient(180deg,#23e6ff,#06bede);box-shadow:0 0 22px rgba(0,217,255,.18)}.ready{font-size:7px;color:#4d7f8e;letter-spacing:.16em}.ready span{display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);margin-right:7px}
|
||||
footer{display:flex;justify-content:space-between;padding:8px 2px;color:#284b5a;font-size:7px;letter-spacing:.18em}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media(max-width:1250px){.brand{width:205px}.system-status{display:none}.metric-row{grid-template-columns:1.2fr 1fr 1fr 1fr}.action-stack{grid-column:span 4;flex-direction:row}.action-stack>*{flex:1}}
|
||||
@media(max-width:900px){.app{grid-template-columns:1fr;grid-template-rows:auto auto 1fr}.topbar{flex-wrap:wrap;height:auto;min-height:78px;padding:10px 15px}.main-nav{order:3;width:100%;overflow:auto;padding-bottom:4px}.sidebar{border-right:0;border-bottom:1px solid #0a4053}.vehicle-list{display:flex;overflow:auto}.vehicle{min-width:150px}.sidebar-label{display:none}.content{padding:12px}.industrial-hero{inset:0;opacity:.3}.metric-row{grid-template-columns:1fr 1fr}.action-stack{grid-column:span 2}.form{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:560px){.user>div:nth-child(2),.user button{display:none}.metric-row,.form{grid-template-columns:1fr}.action-stack{grid-column:auto;flex-direction:column}.status{display:none}.brand-caption{display:none}}
|
||||
@@ -0,0 +1,215 @@
|
||||
import React from "react";
|
||||
import "./ScalesAppCyberpunk.css";
|
||||
|
||||
const trucks = [
|
||||
{ plate: "B7898TP", weight: "41805 kg", live: true },
|
||||
{ plate: "B8710CB", weight: "15350 kg", live: false },
|
||||
{ plate: "B8768TP", weight: "44490 kg", live: true },
|
||||
{ plate: "dddd", weight: "—", live: false },
|
||||
{ plate: "Kiro", weight: "36400 kg", live: true },
|
||||
{ plate: "Krasi", weight: "—", live: false },
|
||||
{ plate: "KOKO", weight: "17000 kg", live: false },
|
||||
];
|
||||
|
||||
const history = [
|
||||
["warehouse", "Товародател 1", "Спедитор 1", "ff", "d1"],
|
||||
["cargo 1", "ship", "Спедитор 1", "ff", "d1"],
|
||||
["cargo 1", "—", "Спедитор 1", "—", "—"],
|
||||
["cargo 1", "Товародател 1", "Спедитор 1", "ff", "d1"],
|
||||
];
|
||||
|
||||
function Icon({ type, size = 18 }) {
|
||||
const p = {
|
||||
width: size, height: size, viewBox: "0 0 24 24",
|
||||
fill: "none", stroke: "currentColor", strokeWidth: 1.7,
|
||||
strokeLinecap: "round", strokeLinejoin: "round"
|
||||
};
|
||||
const d = {
|
||||
truck: <><path d="M3 6h11v11H3z"/><path d="M14 10h4l3 3v4h-7z"/><circle cx="7" cy="19" r="2"/><circle cx="18" cy="19" r="2"/></>,
|
||||
dashboard: <><path d="M4 13a8 8 0 1 1 16 0"/><path d="M12 13l4-5"/><path d="M7 17h10"/></>,
|
||||
file: <><path d="M6 3h9l4 4v14H6z"/><path d="M15 3v5h5"/><path d="M9 12h6M9 16h6"/></>,
|
||||
users: <><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 20c.7-4 3-6 6-6s5.3 2 6 6"/><path d="M14 14c3-.2 5 1.8 6 5"/></>,
|
||||
gear: <><circle cx="12" cy="12" r="3"/><path d="M19 15l2 1-2 3-2-1a8 8 0 0 1-2 1l-.2 2h-3.6l-.2-2a8 8 0 0 1-2-1l-2 1-2-3 2-1a8 8 0 0 1 0-3l-2-1 2-3 2 1a8 8 0 0 1 2-1l.2-2h3.6l.2 2a8 8 0 0 1 2 1l2-1 2 3-2 1a8 8 0 0 1 0 3z"/></>,
|
||||
chart: <><path d="M4 19V9M10 19V5M16 19v-8M22 19V3"/></>,
|
||||
scale: <><path d="M5 5h14M12 5v15M7 20h10"/><path d="m5 8-3 6h6zM19 8l-3 6h6z"/></>,
|
||||
print: <><path d="M6 9V3h12v6"/><path d="M5 18H3a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h18a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 14h12v7H6z"/></>,
|
||||
edit: <><path d="m4 20 4-1 10-10-3-3L5 16z"/><path d="m14 7 3 3"/></>,
|
||||
search: <><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></>,
|
||||
save: <><path d="M5 3h12l2 2v16H5z"/><path d="M8 3v6h8V3M8 21v-7h8v7"/></>,
|
||||
database: <><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v7c0 2 3.6 3 8 3s8-1 8-3V5"/><path d="M4 12v7c0 2 3.6 3 8 3s8-1 8-3v-7"/></>,
|
||||
chevron: <path d="m7 10 5 5 5-5"/>,
|
||||
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
|
||||
check: <path d="m5 12 4 4L19 6"/>,
|
||||
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||||
};
|
||||
return <svg {...p}>{d[type]}</svg>;
|
||||
}
|
||||
|
||||
function Brand() {
|
||||
return (
|
||||
<div className="brand">
|
||||
<div className="brand-emblem"><Icon type="scale" size={27}/></div>
|
||||
<div>
|
||||
<div className="brand-name">SCALESAPP</div>
|
||||
<div className="brand-caption">REAL-TIME DATA MONITOR</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopNav({ icon, label, active }) {
|
||||
return <button className={`top-nav ${active ? "active" : ""}`}>
|
||||
<Icon type={icon} size={19}/><span>{label}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
function Metric({ label, value, tone = "", big = false }) {
|
||||
return (
|
||||
<div className={`metric-card ${tone} ${big ? "big" : ""}`}>
|
||||
<div className="metric-glow"/>
|
||||
<div className="metric-label"><span className="metric-icon"><Icon type="scale" size={15}/></span>{label}</div>
|
||||
<div className="metric-value">{value}</div>
|
||||
<div className="metric-time">2/6/2026 01:18 AM <b>(admin)</b></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ label, value, className = "" }) {
|
||||
return (
|
||||
<label className={`field ${className}`}>
|
||||
<span>{label}</span>
|
||||
<div className="control">
|
||||
<span>{value}</span><Icon type="chevron" size={13}/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function IndustrialHero() {
|
||||
return (
|
||||
<div className="industrial-hero">
|
||||
{/* The photograph carries the scene; the layers over it (vignette, scan,
|
||||
haze, neon) are what tie it to the rest of the UI rather than leaving
|
||||
it looking like a stock image dropped into a panel. */}
|
||||
<div className="hero-photo"/>
|
||||
<div className="hero-vignette"/>
|
||||
<div className="hero-scan"/>
|
||||
<div className="hero-haze haze-a"/>
|
||||
<div className="hero-haze haze-b"/>
|
||||
<div className="hero-neon neon-a"/>
|
||||
<div className="hero-neon neon-b"/>
|
||||
<div className="hero-neon neon-c"/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScalesAppCyberpunk() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<Brand/>
|
||||
<nav className="main-nav">
|
||||
<TopNav icon="truck" label="VEHICLES" active/>
|
||||
<TopNav icon="file" label="REPORTS"/>
|
||||
<TopNav icon="users" label="USERS"/>
|
||||
<TopNav icon="gear" label="SETTINGS"/>
|
||||
<TopNav icon="chart" label="ANALYTICS"/>
|
||||
</nav>
|
||||
<div className="system-status">
|
||||
<span className="pulse"/>
|
||||
<div><strong>SYSTEM ONLINE</strong><small>Everything keeps working for 14 more days.</small></div>
|
||||
</div>
|
||||
<div className="user">
|
||||
<div className="avatar">AD</div>
|
||||
<div><b>admin</b><small>(employee)</small></div>
|
||||
<button>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className="sidebar">
|
||||
<button className="new-vehicle"><Icon type="plus" size={16}/> NEW VEHICLE</button>
|
||||
<div className="search"><Icon type="search" size={15}/><input placeholder="Search vehicle..."/></div>
|
||||
<div className="vehicle-list">
|
||||
{trucks.map((t, i) => (
|
||||
<button className={`vehicle ${i === 0 ? "selected" : ""}`} key={t.plate}>
|
||||
<Icon type="truck" size={17}/>
|
||||
<span className={`vehicle-dot ${t.live ? "live" : ""}`}/>
|
||||
<b>{t.plate}</b><small>{t.weight}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-label"><span>◈</span> WEIGH STATION</div>
|
||||
</aside>
|
||||
|
||||
<main className="content">
|
||||
<section className="vehicle-hero">
|
||||
<div className="vehicle-heading">
|
||||
<div className="vehicle-icon"><Icon type="truck" size={28}/></div>
|
||||
<div>
|
||||
<h1>B7898TP</h1>
|
||||
<div className="crumb">SCALES <i>›</i> LIVE WEIGHING</div>
|
||||
</div>
|
||||
<span className="status active">● ACTIVE</span>
|
||||
<span className="status scale">◉ ON SCALE</span>
|
||||
</div>
|
||||
<IndustrialHero/>
|
||||
</section>
|
||||
|
||||
<section className="metric-row">
|
||||
<Metric label="CURRENT READING" value="3200 kg" tone="current" big/>
|
||||
<Metric label="TARE" value="41805 kg"/>
|
||||
<Metric label="GROSS" value="27735 kg"/>
|
||||
<Metric label="NET" value="-14070 kg" tone="net"/>
|
||||
<div className="action-stack">
|
||||
<button className="print"><Icon type="print" size={17}/> PRINT NOTICE</button>
|
||||
<button className="design"><Icon type="edit" size={16}/> DESIGN NOTICE <Icon type="chevron" size={13}/></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="data-card">
|
||||
<div className="card-header">
|
||||
<div><Icon type="database" size={18}/><b>Additional Data</b></div>
|
||||
<span>DATA // 07</span>
|
||||
</div>
|
||||
|
||||
<div className="history-header">
|
||||
<div><Icon type="file" size={14}/> PREVIOUSLY USED</div>
|
||||
<button>VIEW ALL <span>→</span></button>
|
||||
</div>
|
||||
|
||||
<div className="table-shell">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ТОВАР</th><th>ИДВА ОТ</th><th>ТОВАРОДАТЕЛ</th>
|
||||
<th>СПЕДИТОР</th><th>АГЕНТ</th><th>КОРАБОСОБСТВЕНИК</th><th/>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{history.map((r, i) => <tr key={i}>
|
||||
{r.map((v,j)=><td key={j}>{v}</td>)}
|
||||
<td><button className="remove">×</button></td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="form">
|
||||
<Select label="ТОВАР" value="ship"/>
|
||||
<Select label="ИДВА ОТ" value="ship"/>
|
||||
<Select label="ТОВАРОДАТЕЛ" value="cargo 1"/>
|
||||
<Select label="АГЕНТ" value="ff"/>
|
||||
<Select label="СПЕДИТОР" value="Спедитор 1"/>
|
||||
<Select label="КОРАБОСОБСТВЕНИК" value="d1"/>
|
||||
<label className="field"><span>№ КОНТЕЙНЕР</span><div className="control">KKKK111113</div></label>
|
||||
</div>
|
||||
|
||||
<div className="bottom-actions">
|
||||
<button className="save"><Icon type="save" size={15}/> SAVE EXTRA DATA</button>
|
||||
<div className="ready"><span/> READY FOR NEXT WEIGH</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer><span>LOAD // WEIGH // REPEAT</span><span>SCALESAPP // CONTROL TERMINAL</span></footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -426,6 +426,24 @@ export const NomenclatureProvider = ({ children }) => {
|
||||
...prev,
|
||||
[nomenclatureKey]: updatedArray
|
||||
};
|
||||
} else if (operation === 'archived') {
|
||||
// Vehicle finalized (printed/downloaded) - remove from the active list
|
||||
const filteredArray = currentArray.filter(item => item.id !== data.id);
|
||||
return {
|
||||
...prev,
|
||||
[nomenclatureKey]: filteredArray
|
||||
};
|
||||
} else if (operation === 'voided') {
|
||||
// Vehicle voided - update in place if still present; safe no-op otherwise
|
||||
const exists = currentArray.some(item => item.id === data.id);
|
||||
if (!exists) {
|
||||
return prev;
|
||||
}
|
||||
const updatedArray = updateSorted(currentArray, data, nomenclatureKey);
|
||||
return {
|
||||
...prev,
|
||||
[nomenclatureKey]: updatedArray
|
||||
};
|
||||
}
|
||||
|
||||
return prev;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { createContext, useState, useEffect, useContext, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Theme selection, applied as `data-theme` on <html>.
|
||||
*
|
||||
* The attribute lives on documentElement rather than on a React root div so
|
||||
* that anything rendered outside the tree — portals, overlays, the native
|
||||
* scrollbar via color-scheme — picks the theme up too.
|
||||
*
|
||||
* `classic` is the default on purpose: an operator's screen must not change
|
||||
* because a new theme shipped. Cockpit is opt-in, per browser.
|
||||
*/
|
||||
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
export const THEMES = [
|
||||
{ id: 'classic', label: 'Classic' },
|
||||
{ id: 'cockpit', label: 'Cockpit' }
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'scalesapp.theme';
|
||||
const DEFAULT_THEME = 'classic';
|
||||
|
||||
const isValid = (id) => THEMES.some(t => t.id === id);
|
||||
|
||||
/** Read the stored choice. localStorage throws in some privacy modes, and can
|
||||
* hold a theme id that no longer exists, so both are handled here once. */
|
||||
const readStored = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return isValid(saved) ? saved : DEFAULT_THEME;
|
||||
} catch {
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
};
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setThemeState] = useState(readStored);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((id) => {
|
||||
if (!isValid(id)) return;
|
||||
setThemeState(id);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
} catch {
|
||||
// A theme that cannot be remembered is still worth applying for this
|
||||
// session, so a storage failure is not an error the user needs to see.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState(prev => {
|
||||
const next = prev === 'classic' ? 'cockpit' : 'classic';
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch { /* see above */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme, themes: THEMES }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export default ThemeContext;
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './styles/tokens.css';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
@@ -5,7 +7,8 @@ body {
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
code {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shapes a vehicle for the report preview/design/print machinery.
|
||||
*
|
||||
* Transforms extra.data keys from raw nomenclature codes/entry ids into
|
||||
* { [nomenclature name]: display value }, and attaches the derived net
|
||||
* weight. Shared between Main.jsx (the active weighing screen) and History
|
||||
* (completed vehicles) so both feed the same {vehicle} shape into
|
||||
* ReportPreviewModal/printA4Report.
|
||||
*
|
||||
* @param {Object} vehicle - a vehicle record (as returned by the API)
|
||||
* @param {Object} definitions - nomenclature definitions keyed by code
|
||||
* @param {Object} entries - lookup entries keyed by code
|
||||
* @returns {{vehicle: Object} | null}
|
||||
*/
|
||||
export function buildVehicleReportData(vehicle, definitions, entries) {
|
||||
if (!vehicle) return null;
|
||||
const rawExtra = vehicle.extra?.data || {};
|
||||
const extra = {};
|
||||
for (const [code, value] of Object.entries(rawExtra)) {
|
||||
const def = definitions[code];
|
||||
if (!def) continue;
|
||||
if (def.kind === 'lookup') {
|
||||
const entry = (entries[code] || []).find(e => e.id === value);
|
||||
extra[def.name] = entry?.display_value ?? '';
|
||||
} else {
|
||||
extra[def.name] = value ?? '';
|
||||
}
|
||||
}
|
||||
const netWeight = (vehicle.tare != null && vehicle.gross != null)
|
||||
? vehicle.gross - vehicle.tare
|
||||
: null;
|
||||
return {
|
||||
vehicle: {
|
||||
...vehicle,
|
||||
net: vehicle.net ?? netWeight,
|
||||
extra,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Design tokens — the single place every colour, radius and shadow is defined.
|
||||
*
|
||||
* Two themes today: `classic` (the app's original light look) and `cockpit`
|
||||
* (dark, derived from the weigh-station reference image). A third theme is one
|
||||
* more block here and nothing else, which is why the token names are SEMANTIC
|
||||
* (--surface, --accent) and never literal (--navy, --cyan) — a name that
|
||||
* describes a colour stops being true the moment another theme uses a
|
||||
* different one.
|
||||
*
|
||||
* The `classic` values are lifted verbatim from the stylesheets they replaced,
|
||||
* so switching to classic must be visually indistinguishable from the app
|
||||
* before theming existed. That is the acceptance test for this file.
|
||||
*
|
||||
* Cockpit's palette is sampled from src/assets/weigh-station-banner.jpg rather
|
||||
* than invented, which is what keeps it coherent: the navies, steel mid-tones
|
||||
* and cyan all already sit together in one photograph.
|
||||
*/
|
||||
|
||||
:root,
|
||||
:root[data-theme="classic"] {
|
||||
/* Surfaces, back to front */
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-sunken: #fafafa;
|
||||
--surface-hover: #f0f2f5;
|
||||
|
||||
/* Lines */
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #c8ccd4;
|
||||
|
||||
/* Type */
|
||||
--text: #2c3e50;
|
||||
--text-muted: #7f8c9a;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Brand / interactive */
|
||||
--accent: #667eea;
|
||||
--accent-hover: #5a6fd6;
|
||||
--accent-contrast: #ffffff;
|
||||
--accent-soft: rgba(102, 126, 234, 0.12);
|
||||
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
/* States */
|
||||
--success: #27ae60;
|
||||
--success-soft: rgba(39, 174, 96, 0.12);
|
||||
--warning: #e67e22;
|
||||
--warning-soft: rgba(230, 126, 34, 0.12);
|
||||
--danger: #e74c3c;
|
||||
--danger-soft: rgba(231, 76, 60, 0.12);
|
||||
|
||||
/* The live scale reading: its own tokens because balanced/unbalanced is the
|
||||
one piece of colour an operator reads from across the room. */
|
||||
--reading: #2c3e50;
|
||||
--reading-balanced: #27ae60;
|
||||
--reading-unbalanced: #e67e22;
|
||||
--reading-glow: none;
|
||||
|
||||
/* Shape and depth */
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-raised: 0 3px 8px rgba(0, 0, 0, 0.12);
|
||||
--shadow-overlay: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
|
||||
/* The header is its own surface: a saturated bar with white chrome on it,
|
||||
so it cannot borrow --surface/--text without losing that contrast. */
|
||||
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--header-text: #ffffff;
|
||||
--header-chrome: rgba(255, 255, 255, 0.2);
|
||||
--header-chrome-hover: rgba(255, 255, 255, 0.3);
|
||||
--header-chrome-border: rgba(255, 255, 255, 0.3);
|
||||
--header-avatar-bg: #ffffff;
|
||||
--header-avatar-text: #667eea;
|
||||
|
||||
/* Cockpit-only chrome, neutralised here so shared rules need no theme test */
|
||||
--hero-image: none;
|
||||
}
|
||||
|
||||
:root[data-theme="cockpit"] {
|
||||
/* Sampled: #000018 / #183048 / #001830 dominate the reference image */
|
||||
--bg: #020a12;
|
||||
--surface: #061726;
|
||||
--surface-raised: #0a2336;
|
||||
--surface-sunken: #03101c;
|
||||
--surface-hover: #0e3047;
|
||||
|
||||
--border: #0d4f66;
|
||||
--border-strong: #12809e;
|
||||
|
||||
--text: #d9f6ff;
|
||||
--text-muted: #6e9caf;
|
||||
--text-inverse: #070d18;
|
||||
|
||||
/* Sampled cyan, lifted in saturation: the photo's cyan is atmospheric haze,
|
||||
which is too dim to carry a button label. */
|
||||
--accent: #00d9ff;
|
||||
--accent-hover: #5ce8ff;
|
||||
--accent-contrast: #00232e;
|
||||
--accent-soft: rgba(0, 217, 255, 0.14);
|
||||
--accent-gradient: linear-gradient(135deg, #00d9ff 0%, #19a9ff 100%);
|
||||
|
||||
--success: #00f19b;
|
||||
--success-soft: rgba(0, 241, 155, 0.14);
|
||||
--warning: #ff6b24;
|
||||
--warning-soft: rgba(255, 107, 36, 0.16);
|
||||
--danger: #ff4d4d;
|
||||
--danger-soft: rgba(226, 87, 76, 0.18);
|
||||
|
||||
--reading: #00d9ff;
|
||||
--reading-balanced: #00f19b;
|
||||
--reading-unbalanced: #ff9a3c;
|
||||
/* The glow is what makes the reading read as a lit instrument rather than
|
||||
text on a dark box. Kept to the reading alone — everywhere else it is
|
||||
noise that costs contrast. */
|
||||
--reading-glow: 0 0 10px rgba(0, 217, 255, .75), 0 0 28px rgba(0, 217, 255, .45);
|
||||
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--radius-lg: 12px;
|
||||
/* Depth on dark comes from a lit top edge, not a drop shadow: a black shadow
|
||||
on a near-black background is invisible. */
|
||||
--shadow-card: 0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
--shadow-raised: 0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||
0 6px 18px rgba(0, 0, 0, 0.6);
|
||||
--shadow-overlay: 0 12px 48px rgba(0, 0, 0, 0.7);
|
||||
|
||||
--header-bg: linear-gradient(180deg, #0a2336 0%, #04121e 100%);
|
||||
--header-text: #d9f6ff;
|
||||
--header-chrome: rgba(0, 217, 255, 0.10);
|
||||
--header-chrome-hover: rgba(0, 217, 255, 0.22);
|
||||
--header-chrome-border: rgba(0, 217, 255, 0.34);
|
||||
--header-avatar-bg: #00d9ff;
|
||||
--header-avatar-text: #00232e;
|
||||
|
||||
/* Wide crop of the reference photograph, shown beside the vehicle title in
|
||||
.vehicle-hero-photo, scaled proportionally (background-size: contain)
|
||||
rather than cropped or stretched. */
|
||||
--hero-image: url('../assets/bar2.png');
|
||||
|
||||
/* Lighting. A lit edge is a gradient that fades at both ends — a flat border
|
||||
reads as a box, the fade reads as a light source above the card. */
|
||||
--edge-lit: linear-gradient(90deg, transparent, var(--accent) 18%, var(--accent) 82%, transparent);
|
||||
--edge-lit-net: linear-gradient(90deg, transparent, var(--success) 22%, var(--success) 78%, transparent);
|
||||
--glow-accent: 0 0 26px rgba(0, 217, 255, .30), 0 0 60px rgba(0, 217, 255, .10);
|
||||
--glow-success: 0 0 26px rgba(0, 241, 155, .30), 0 0 60px rgba(0, 241, 155, .10);
|
||||
--glow-warning: 0 0 26px rgba(255, 107, 36, .45), 0 0 60px rgba(255, 107, 36, .15);
|
||||
--glow-danger: 0 0 26px rgba(255, 77, 77, .40), 0 0 60px rgba(255, 77, 77, .15);
|
||||
--hazard: repeating-linear-gradient(-45deg, var(--warning) 0 7px, transparent 7px 15px);
|
||||
}
|
||||
|
||||
/* Native form controls and scrollbars follow the theme, so a dark theme does
|
||||
not get a white dropdown or a light scrollbar track. */
|
||||
:root[data-theme="cockpit"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
Reference in New Issue
Block a user