barebone app with django and react, sse, jwt token, comport reader, test comport writer, requires com0com, users with groups, sample table vehicles, tokens for access and refresh

This commit is contained in:
2026-01-17 13:03:21 +02:00
commit 7f04566242
81 changed files with 22551 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
.App {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
}
.status-bar {
background: white;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.status-connected {
color: #4CAF50;
font-size: 14px;
font-weight: 500;
}
.status-disconnected {
color: #f44336;
font-size: 14px;
font-weight: 500;
}
.status-error {
color: #f44336;
font-size: 12px;
}
.container {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
flex: 1;
width: 100%;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 12px 20px;
border-radius: 4px;
margin-bottom: 20px;
border: 1px solid #f5c6cb;
}
.loading {
text-align: center;
padding: 40px;
font-size: 18px;
color: #666;
}
@media (max-width: 768px) {
.status-bar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.container {
padding: 0 10px;
}
}
+73
View File
@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
// import ProtectedRoute from './components/ProtectedRoute';
import Login from './components/Users/Login';
import Main from './components/Main';
import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
// function MainApp() {
// const [selectedPort, setSelectedPort] = useState(null);
// const { readings, isConnected, error } = useSerialData();
// const filteredReadings = selectedPort
// ? readings.filter(r => r.port === selectedPort)
// : readings;
// return (
// <div className="App">
// <Header />
// <div className="status-bar">
// {isConnected ? (
// <span className="status-connected">● Connected to Serial Bridge</span>
// ) : (
// <span className="status-disconnected">● Disconnected</span>
// )}
// {error && (
// <span className="status-error">{error}</span>
// )}
// </div>
// <div className="container">
// <DataDisplay readings={filteredReadings} selectedPort={selectedPort} />
// </div>
// </div>
// );
// }
function AppContent() {
const { user, login, logout, loading, isAuthenticated } = useAuth();
if (loading) {
return (
<div className="loading">
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Login onLogin={login} />;
}
return (
<NomenclatureProvider>
<Main />
</NomenclatureProvider>
);
}
function App() {
return (
<Router>
<AuthProvider>
<AppContent />
</AuthProvider>
</Router>
);
}
export default App;
+135
View File
@@ -0,0 +1,135 @@
.data-display {
margin-top: 20px;
}
.data-display.empty {
background: white;
padding: 40px;
border-radius: 8px;
text-align: center;
color: #999;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reading-card {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.reading-card h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.reading-card.latest {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-left: 4px solid #667eea;
}
.reading-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 15px;
}
.info-item {
padding: 10px;
background: white;
border-radius: 4px;
}
.info-item label {
display: block;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
font-size: 14px;
}
.info-item span {
display: block;
font-size: 16px;
color: #333;
word-break: break-all;
}
.data-value {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
font-weight: bold;
}
.readings-history {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.readings-history h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.table-container {
overflow-x: auto;
margin-top: 15px;
}
table {
width: 100%;
border-collapse: collapse;
}
table thead {
background-color: #f5f5f5;
}
table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #667eea;
border-bottom: 2px solid #ddd;
}
table td {
padding: 12px;
border-bottom: 1px solid #eee;
}
table tbody tr:hover {
background-color: #f9f9f9;
}
.data-cell {
font-family: 'Courier New', monospace;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
word-break: break-all;
}
@media (max-width: 768px) {
.reading-info {
grid-template-columns: 1fr;
}
table {
font-size: 14px;
}
table th, table td {
padding: 8px;
}
}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import './DataDisplay.css';
function DataDisplay({ readings, selectedPort }) {
if (!readings || readings.length === 0) {
return (
<div className="data-display empty">
<p>No data available. Waiting for COM port readings...</p>
</div>
);
}
const latestReading = readings[0];
return (
<div className="data-display">
<div className="reading-card latest">
<h2>Latest Reading</h2>
<div className="reading-info">
<div className="info-item">
<label>Port:</label>
<span>{latestReading.port}</span>
</div>
<div className="info-item">
<label>Data:</label>
<span className="data-value">{latestReading.data}</span>
</div>
<div className="info-item">
<label>Time:</label>
<span>{new Date(latestReading.timestamp).toLocaleString()}</span>
</div>
</div>
</div>
{/* <div className="readings-history">
<h2>Recent Readings ({readings.length})</h2>
<div className="table-container">
<table>
<thead>
<tr>
<th>Port</th>
<th>Data</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{readings.map((reading, idx) => (
<tr key={reading.id || idx}>
<td>{reading.port}</td>
<td className="data-cell">{reading.data}</td>
<td>{new Date(reading.timestamp).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> */}
</div>
);
}
export default DataDisplay;
+112
View File
@@ -0,0 +1,112 @@
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1400px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-right: 10px;
}
.user-name {
font-weight: 600;
font-size: 14px;
}
.user-role {
font-size: 12px;
opacity: 0.9;
}
.avatar-container {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: white;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.avatar:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.logout-button {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.3);
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.header-left h1 {
font-size: 18px;
text-align: center;
}
.header-right {
justify-content: space-between;
}
.user-info {
align-items: flex-start;
margin-right: 0;
}
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const getInitials = (user) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user.username.substring(0, 2).toUpperCase();
};
const handleAvatarClick = () => {
setShowPasswordOverlay(true);
};
return (
<>
<header className="app-header">
<div className="header-content">
<div className="header-left">
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">
<div className="user-info">
<span className="user-name">{currentUser?.username}</span>
<span className="user-role">({currentUser?.role})</span>
</div>
<div className="avatar-container">
<div className="avatar" onClick={handleAvatarClick} title="Change Password">
{getInitials(currentUser || {})}
</div>
<button className="logout-button" onClick={logout} title="Logout">
Logout
</button>
</div>
</div>
</div>
</header>
{showPasswordOverlay && (
<ChangePasswordOverlay onClose={() => setShowPasswordOverlay(false)} />
)}
</>
);
}
export default Header;
+35
View File
@@ -0,0 +1,35 @@
import Header from './Header';
import DataDisplay from './DataDisplay';
import useSerialData from '../hooks/useSerialData';
import { useNomenclatures } from '../contexts/NomenclatureContext';
export default function Main() {
const { readings, isConnected, error } = useSerialData();
const { vehicles } = useNomenclatures();
console.log('Vehicles:', vehicles);
return (
<div className="main">
<div>
<div className="vehicles">
{ vehicles.map(vehicle => {
return (
<div key={vehicle.id} className="vehicle-card">
<h3>{vehicle.vehicle_number}</h3>
</div>
);
})}
</div>
</div>
<div className="vehicles-data">
<div className="data-header">
<h2>Vehicle Data</h2>
</div>
<div className="data-list">
<Header />
<DataDisplay readings={readings}/>
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return (
<div className="loading">
<div className="loading-spinner"></div>
<p>Loading...</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
export default ProtectedRoute;
@@ -0,0 +1,220 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay-content {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.overlay-header {
padding: 24px 24px 16px;
border-bottom: 2px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
}
.overlay-header h2 {
margin: 0;
color: #667eea;
font-size: 24px;
}
.close-button {
background: none;
border: none;
font-size: 32px;
color: #999;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.close-button:hover {
background: #f5f5f5;
color: #333;
}
.password-form {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.cancel-button,
.submit-button {
flex: 1;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.cancel-button {
background: white;
border: 2px solid #ddd;
color: #666;
}
.cancel-button:hover:not(:disabled) {
border-color: #999;
color: #333;
}
.submit-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.cancel-button:disabled,
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
}
.success-message {
padding: 40px 24px;
text-align: center;
}
.success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
margin: 0 auto 16px;
animation: scaleIn 0.4s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.success-message p {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 500;
}
@media (max-width: 540px) {
.overlay-content {
max-width: 100%;
}
.password-form {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
}
@@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import './ChangePasswordOverlay.css';
function ChangePasswordOverlay({ onClose }) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { changePassword } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Validation
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (oldPassword === newPassword) {
setError('New password must be different from old password');
return;
}
setIsLoading(true);
const result = await changePassword(oldPassword, newPassword);
if (result.success) {
setSuccess(true);
setTimeout(() => {
onClose();
}, 2000);
} else {
// Extract error message
let errorMsg = 'Failed to change password';
if (result.error.old_password) {
errorMsg = result.error.old_password[0];
} else if (result.error.new_password) {
errorMsg = result.error.new_password[0];
} else if (result.error.detail) {
errorMsg = result.error.detail;
}
setError(errorMsg);
setIsLoading(false);
}
};
const handleOverlayClick = (e) => {
if (e.target.className === 'overlay') {
onClose();
}
};
return (
<div className="overlay" onClick={handleOverlayClick}>
<div className="overlay-content">
<div className="overlay-header">
<h2>Change Password</h2>
<button className="close-button" onClick={onClose} aria-label="Close">
&times;
</button>
</div>
{success ? (
<div className="success-message">
<div className="success-icon"></div>
<p>Password changed successfully!</p>
</div>
) : (
<form onSubmit={handleSubmit} className="password-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="old-password">Current Password</label>
<input
id="old-password"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Enter current password"
required
autoFocus
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="confirm-password">Confirm New Password</label>
<input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter new password"
required
disabled={isLoading}
/>
</div>
<div className="form-actions">
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="submit-button"
disabled={isLoading}
>
{isLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default ChangePasswordOverlay;
+141
View File
@@ -0,0 +1,141 @@
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
margin: 0 0 10px 0;
color: #667eea;
font-size: 32px;
}
.login-header p {
margin: 0;
color: #666;
font-size: 16px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-group input {
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.form-group input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.login-button {
padding: 14px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 10px;
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.login-button:active:not(:disabled) {
transform: translateY(0);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: #fee;
color: #c33;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #fcc;
font-size: 14px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.loading-spinner {
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.login-card {
padding: 30px 20px;
}
.login-header h1 {
font-size: 28px;
}
}
+90
View File
@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import './Login.css';
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setIsLoading(true);
const result = await login(username, password);
if (result.success) {
navigate('/');
} else {
setError(result.error);
setIsLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1>ScalesApp</h1>
<p>Sign in to continue</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
{error && (
<div className="error-message">
{error}
</div>
)}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
autoFocus
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}
export default Login;
+141
View File
@@ -0,0 +1,141 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Initialize auth state from localStorage
useEffect(() => {
const initAuth = async () => {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (accessToken && refreshToken) {
try {
// Validate token by fetching user info
const userData = await fetchCurrentUser();
setCurrentUser(userData);
setIsAuthenticated(true);
} catch (error) {
console.error('Token validation failed:', error);
clearTokens();
}
}
setIsLoading(false);
};
initAuth();
}, []);
// Auto-refresh token before expiry
useEffect(() => {
if (!isAuthenticated) return;
// Refresh token every 14 minutes (tokens expire at 15 min)
const refreshInterval = setInterval(async () => {
try {
await refreshAccessToken();
} catch (error) {
console.error('Token refresh failed:', error);
logout();
}
}, 14 * 60 * 1000); // 14 minutes
return () => clearInterval(refreshInterval);
}, [isAuthenticated]);
const fetchCurrentUser = async () => {
const response = await api.get('/api/users/me/');
return response.data;
};
const login = async (username, password) => {
try {
// Get tokens
const response = await api.post('/api/token/', { username, password });
const { access, refresh } = response.data;
// Store tokens
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
// Fetch user data
const userData = await fetchCurrentUser();
setCurrentUser(userData);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
console.error('Login failed:', error);
return {
success: false,
error: error.response?.data?.detail || 'Login failed. Please check your credentials.'
};
}
};
const logout = () => {
clearTokens();
setCurrentUser(null);
setIsAuthenticated(false);
};
const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await api.post('/api/token/refresh/', {
refresh: refreshToken
});
const { access } = response.data;
localStorage.setItem('accessToken', access);
return access;
};
const clearTokens = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
};
const changePassword = async (oldPassword, newPassword) => {
try {
await api.post('/api/users/change-password/', {
old_password: oldPassword,
new_password: newPassword
});
return { success: true };
} catch (error) {
console.error('Password change failed:', error);
return {
success: false,
error: error.response?.data || 'Password change failed.'
};
}
};
const value = {
currentUser,
isAuthenticated,
isLoading,
login,
logout,
refreshAccessToken,
changePassword,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
@@ -0,0 +1,452 @@
import { createContext, useContext, useState, useEffect, useRef } from 'react';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const NomenclatureContext = createContext();
// Centralized nomenclature configuration
const NOMENCLATURES_CONFIG = [
{ key: 'vehicles', jsonKey: 'vehicles', sortBy: 'name', endpoint: '/api/vehicles/', sseObjectType: 'vehicle' },
// { key: 'vesselOwners', jsonKey: 'vessel_owners', sortBy: 'name', endpoint: '/vessel_owners/api/list/', sseObjectType: 'vessel_owner' },
// { key: 'agents', jsonKey: 'agents', sortBy: 'name', endpoint: '/agents/api/list/', sseObjectType: 'agent' },
// { key: 'objects', jsonKey: 'objects', sortBy: 'id', endpoint: '/objects/api/list/', sseObjectType: 'object' },
// { key: 'cargos', jsonKey: 'cargos', sortBy: 'name', endpoint: '/cargos/api/list/', sseObjectType: 'cargo' },
// { key: 'groups', jsonKey: 'groups', sortBy: 'id', endpoint: '/groups/api/list/', sseObjectType: 'group' },
// { key: 'groupSmalls', jsonKey: 'groupsmalls', sortBy: 'id', endpoint: '/groups/api/getgroupsmalls/', sseObjectType: 'groupsmalls' },
// { key: 'tariffs', jsonKey: 'tariffs', sortBy: 'id', endpoint: '/tariff/api/list/', sseObjectType: 'tariff' },
// { key: 'tariffSmalls', jsonKey: 'tariffsmalls', sortBy: 'id', endpoint: '/tariff/api/tariffsmalls', sseObjectType: 'tariffsmalls' },
// { key: 'countries', jsonKey: 'countries', sortBy: 'name', endpoint: '/countries/api/list', sseObjectType: 'country' },
// { key: 'passThroughs', jsonKey: 'passthroughs', sortBy: 'id', endpoint: '/tariff/api/getpassthroughs/', sseObjectType: 'passthrough' },
// { key: 'vats', jsonKey: 'vats', sortBy: 'id', endpoint: '/tariff/api/getvats/', sseObjectType: 'vat' },
// { key: 'maneuvers', jsonKey: 'maneuvers', sortBy: 'id', endpoint: '/common/api/maneuvers/', sseObjectType: 'maneuver' },
// { key: 'prices', jsonKey: 'prices', sortBy: 'id', endpoint: '/tariff/api/prices/', sseObjectType: 'prices' },
// { key: 'bankAccounts', jsonKey: 'bank_accounts', sortBy: 'id', endpoint: '/common/api/bank-accounts/', sseObjectType: 'bank_account' },
// { key: 'noticeNotes', jsonKey: 'notice_notes', sortBy: 'name', endpoint: '/invoices/api/notice-notes/list/', sseObjectType: 'notice_note' },
// { key: 'translations', jsonKey: 'translations', sortBy: 'name', endpoint: '/common/api/translations/', sseObjectType: 'translation' }
];
// Helper function to get config for a specific key
const getConfigForKey = (key) => NOMENCLATURES_CONFIG.find(c => c.key === key);
// Helper function to get nomenclature key from SSE object type
const getNomenclatureKeyFromSSE = (sseObjectType) => {
const config = NOMENCLATURES_CONFIG.find(c => c.sseObjectType === sseObjectType);
return config?.key;
};
export const useNomenclatures = () => {
const context = useContext(NomenclatureContext);
if (!context) {
throw new Error('useNomenclatures must be used within a NomenclatureProvider');
}
return context;
};
// Utility function to sort nomenclature array based on configuration
const sortNomenclature = (array, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
return [...array].sort((a, b) => {
if (orderBy === 'id') {
return (a.id || 0) - (b.id || 0);
} else {
// Order by name (case-insensitive)
const nameA = (a.name || '').toLowerCase();
const nameB = (b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
}
});
};
// Utility function to insert item in sorted position
const insertSorted = (array, newItem, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
const newArray = [...array];
// Find the correct insertion position
let insertIndex = newArray.length;
if (orderBy === 'id') {
insertIndex = newArray.findIndex(item => {
const comparison = item.id > newItem.id;
return comparison;
});
if (insertIndex === -1) insertIndex = newArray.length;
} else {
const newName = (newItem.name || '').toLowerCase();
insertIndex = newArray.findIndex(item =>
(item.name || '').toLowerCase() > newName
);
if (insertIndex === -1) insertIndex = newArray.length;
}
newArray.splice(insertIndex, 0, newItem);
return newArray;
};
// Utility function to update item and maintain sorted order
const updateSorted = (array, updatedItem, nomenclatureKey) => {
const config = getConfigForKey(nomenclatureKey);
const orderBy = config?.sortBy || 'name';
// Remove the old item
const filteredArray = array.filter(item => item.id !== updatedItem.id);
// Check if the ordering field has changed
const oldItem = array.find(item => item.id === updatedItem.id);
if (!oldItem) {
// Item not found, just insert it
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
}
const orderFieldChanged = orderBy === 'id'
? oldItem.id !== updatedItem.id
: (oldItem.name || '').toLowerCase() !== (updatedItem.name || '').toLowerCase();
if (orderFieldChanged) {
// Re-insert in sorted position if the ordering field changed
return insertSorted(filteredArray, updatedItem, nomenclatureKey);
} else {
// Just update in place if ordering hasn't changed
return array.map(item =>
item.id === updatedItem.id ? { ...item, ...updatedItem } : item
);
}
};
// Generate initial state objects from config
const generateInitialState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = [];
return acc;
}, {});
const generateInitialLoadingState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = false;
return acc;
}, {});
const generateInitialErrorState = () => NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = null;
return acc;
}, {});
export const NomenclatureProvider = ({ children }) => {
// Ref to track invoice update subscribers
const subscribersRef = useRef(new Set());
const [nomenclatures, setNomenclatures] = useState(generateInitialState());
const [loading, setLoading] = useState(generateInitialLoadingState());
const [error, setError] = useState(generateInitialErrorState());
const [allLoaded, setAllLoaded] = useState(false);
const [hasStartedLoading, setHasStartedLoading] = useState(false);
// Generic fetch function
const fetchNomenclature = async (key, url) => {
const startTime = performance.now();
try {
const token = localStorage.getItem('accessToken');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
headers,
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Failed to fetch ${key}: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data && typeof data === 'object') {
}
// Normalize data format - adjust based on your API response structure
let normalizedData;
if (Array.isArray(data)) {
normalizedData = data;
} else if (data && typeof data === 'object') {
// Handle different response formats
const config = getConfigForKey(key);
const jsonKey = config?.jsonKey; // Get the correct JSON key from config
normalizedData = data.results || data.data || data[jsonKey] || data[key] || [];
} else {
normalizedData = [];
}
// Sort the data according to configured order
const sortedData = sortNomenclature(normalizedData, key);
setNomenclatures(prev => ({
...prev,
[key]: sortedData
}));
setError(prev => ({
...prev,
[key]: null
}));
} catch (err) {
const totalTime = performance.now() - startTime;
console.error(`❌ [${key}] Failed after ${totalTime.toFixed(2)}ms:`, err.message);
setError(prev => ({
...prev,
[key]: err.message
}));
} finally {
setLoading(prev => ({
...prev,
[key]: false
}));
}
};
// Load all nomenclatures on mount, but only if we have auth
useEffect(() => {
const loadAllNomenclatures = async () => {
// Check if we have an auth token
const token = localStorage.getItem('accessToken');
if (!token) {
return;
}
setHasStartedLoading(true);
// Set all loading states to true
const allLoadingState = NOMENCLATURES_CONFIG.reduce((acc, config) => {
acc[config.key] = true;
return acc;
}, {});
setLoading(allLoadingState);
// Load all nomenclatures in parallel
const promises = NOMENCLATURES_CONFIG.map(config =>
fetchNomenclature(config.key, API_BASE_URL + config.endpoint)
);
await Promise.allSettled(promises);
/*
// Wait a tick for state to update, then apply translations
setTimeout(async () => {
try {
// Fetch translations directly from API to ensure we have the latest data
const token = localStorage.getItem('authToken');
const translationsConfig = getConfigForKey('translations');
const response = await fetch(translationsConfig.endpoint, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${token}`
},
credentials: 'include'
});
if (response.ok) {
const translationsData = await response.json();
if (translationsData && translationsData.length > 0) {
// Dynamic import to avoid circular dependency
const module = await import('@utils/printTranslations');
const { translationService } = await import('@services/translationService');
const merged = translationService.applyOverrides(
module.printTranslations,
translationsData
);
Object.assign(module.printTranslations, merged);
console.log('✅ Translations initialized:', translationsData.length, 'translations loaded');
}
}
} catch (error) {
console.error('Failed to apply translation overrides:', error);
}
}, 100);
*/
setAllLoaded(true);
};
loadAllNomenclatures();
// Also listen for auth token changes
const handleStorageChange = (e) => {
if (e.key === 'accessToken' && e.newValue && !hasStartedLoading) {
loadAllNomenclatures();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [hasStartedLoading]);
// SSE connection for real-time updates with auto-reconnect
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (!token) {
return; // Don't connect if not authenticated
}
let eventSource;
let reconnectTimeout;
let isIntentionallyClosed = false;
const connect = () => {
if (isIntentionallyClosed) return;
eventSource = new EventSource(`${API_BASE_URL}/sse-connect/?token=${token}`);
eventSource.onopen = () => {
// console.log('SSE connection opened');
};
eventSource.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleSSEUpdate(message);
} catch (error) {
console.error('Error parsing SSE message:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
eventSource.close();
// Reconnect after 5 seconds
if (!isIntentionallyClosed) {
console.log('SSE will reconnect in 5 seconds...');
reconnectTimeout = setTimeout(connect, 5000);
}
};
};
// Start initial connection
connect();
// Cleanup on unmount
return () => {
isIntentionallyClosed = true;
if (reconnectTimeout) clearTimeout(reconnectTimeout);
if (eventSource) eventSource.close();
};
}, []);
// Function to subscribe to invoice updates
const subscribeToUpdates = (callback) => {
subscribersRef.current.add(callback);
// Return unsubscribe function
return () => {
subscribersRef.current.delete(callback);
};
};
// Handle SSE updates
const handleSSEUpdate = (message) => {
if (message.type !== 'data_update') {
return; // Only handle data_update messages
}
const { object, operation, data } = message;
// Handle invoice updates - notify all subscribers
if (object === 'invoice' || object === 'notice') {
subscribersRef.current.forEach(callback => {
try {
callback(message);
} catch (error) {
console.error('Error in invoice update subscriber:', error);
}
});
return;
}
// Map object types to nomenclature keys using config
const nomenclatureKey = getNomenclatureKeyFromSSE(object);
if (!nomenclatureKey) {
console.warn('Unknown object type in SSE update:', object);
return;
}
// Special handling for translations: update printTranslations in real-time
// if (nomenclatureKey === 'translations' && (operation === 'insert' || operation === 'update')) {
// import('@utils/printTranslations').then(async (module) => {
// const key = data.key;
// if (module.printTranslations[key]) {
// module.printTranslations[key] = {
// bg: data.text_bg,
// en: data.text_en
// };
// }
// }).catch(error => {
// console.error('Failed to update printTranslations from SSE:', error);
// });
// }
setNomenclatures(prev => {
const currentArray = prev[nomenclatureKey] || [];
if (operation === 'insert') {
// Add new item if it doesn't exist
const exists = currentArray.some(item => item.id === data.id);
if (!exists) {
// Insert in sorted position
const sortedArray = insertSorted(currentArray, data, nomenclatureKey);
return {
...prev,
[nomenclatureKey]: sortedArray
};
}
return prev;
} else if (operation === 'update') {
// Update existing item and maintain sort order
const updatedArray = updateSorted(currentArray, data, nomenclatureKey);
return {
...prev,
[nomenclatureKey]: updatedArray
};
}
return prev;
});
};
const value = {
// Spread all nomenclature data
...nomenclatures,
// Loading states
loading,
allLoaded,
// Error states
error,
// Utility functions
subscribeToUpdates,
// State helpers
hasStartedLoading,
// Helper to check if any is loading
isAnyLoading: Object.values(loading).some(Boolean),
// Helper to check if any has errors
hasErrors: Object.values(error).some(Boolean)
};
return (
<NomenclatureContext.Provider value={value}>
{children}
</NomenclatureContext.Provider>
);
};
+48
View File
@@ -0,0 +1,48 @@
import { useState, useEffect } from 'react';
function useSerialData() {
const [readings, setReadings] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const eventSource = new EventSource('http://localhost:5000/events');
eventSource.onopen = () => {
setIsConnected(true);
setError(null);
console.log('Connected to serial bridge SSE');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.status === 'connected') {
console.log('SSE:', data.message);
} else {
// Add new reading to the beginning
setReadings(prev => [data, ...prev].slice(0, 100)); // Keep last 100
console.log('Received:', data);
}
} catch (err) {
console.error('Error parsing SSE data:', err);
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
setIsConnected(false);
setError('Failed to connect to serial bridge');
eventSource.close();
};
return () => {
eventSource.close();
};
}, []);
return { readings, isConnected, error };
}
export default useSerialData;
+18
View File
@@ -0,0 +1,18 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+106
View File
@@ -0,0 +1,106 @@
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor: Add JWT token to all requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor: Handle 401 errors with token refresh
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
response => response,
async (error) => {
const originalRequest = error.config;
// If error is 401 and we haven't tried refreshing yet
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue this request while refresh is in progress
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// No refresh token, redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
try {
// Attempt to refresh the token
const response = await axios.post(`${API_BASE_URL}/api/token/refresh/`, {
refresh: refreshToken
});
const { access } = response.data;
// Update stored token
localStorage.setItem('accessToken', access);
// Update authorization header
api.defaults.headers.common['Authorization'] = `Bearer ${access}`;
originalRequest.headers.Authorization = `Bearer ${access}`;
// Process queued requests
processQueue(null, access);
isRefreshing = false;
// Retry original request
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, clear tokens and redirect to login
processQueue(refreshError, null);
isRefreshing = false;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
console.error('API Error:', error);
return Promise.reject(error);
}
);
export default api;