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:
@@ -0,0 +1 @@
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@contexts/*": ["contexts/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
"@services/*": ["services/*"],
|
||||
"@assets/*": ["assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+17533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "scalesapp-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:8000"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta
|
||||
name="description"
|
||||
content="ScalesApp - Real-time COM port data monitoring"
|
||||
/>
|
||||
<title>ScalesApp - Data Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
/c/dev_projects/ScalesApp/frontend
|
||||
Reference in New Issue
Block a user