fixed bug with spreadsheet import

This commit is contained in:
GotthardG 2024-12-10 15:18:48 +01:00
parent 996fc66d76
commit e28c8b05d4
25 changed files with 819 additions and 70 deletions

View File

@ -1,6 +1,6 @@
import logging
from sqlalchemy.orm import Session, joinedload
from app.models import Shipment
from .models import Shipment
def get_shipments(db: Session):
logging.info("Fetching all shipments from the database.")

View File

@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app import models
from . import models
import os
# Get username and password from environment variables
@ -31,12 +31,12 @@ def get_db():
def init_db():
# Import models inside function to avoid circular dependency
from app import models
from . import models
Base.metadata.create_all(bind=engine)
def load_sample_data(session: Session):
# Import models inside function to avoid circular dependency
from app.data import contacts, return_addresses, dewars, proposals, shipments, pucks, samples, dewar_types, serial_numbers, slots, sample_events
from .data import contacts, return_addresses, dewars, proposals, shipments, pucks, samples, dewar_types, serial_numbers, slots, sample_events
# If any data already exists, skip seeding
if session.query(models.ContactPerson).first():

View File

@ -1,5 +1,5 @@
# app/dependencies.py
from app.database import SessionLocal # Import SessionLocal from database.py
from .database import SessionLocal # Import SessionLocal from database.py
def get_db():
db = SessionLocal()

View File

@ -1,4 +1,4 @@
from app.database import init_db
from .database import init_db
def initialize_database():

View File

@ -1,6 +1,6 @@
from sqlalchemy import Column, Integer, String, Date, ForeignKey, JSON, Interval, DateTime, Boolean
from sqlalchemy.orm import relationship
from app.database import Base
from .database import Base
from datetime import datetime, timedelta
import uuid

View File

@ -4,16 +4,31 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import ssl_heidi
from pathlib import Path
import os
import json
from app.routers import address, contact, proposal, dewar, shipment, puck, spreadsheet, logistics, auth, sample
from app.database import Base, engine, SessionLocal, load_sample_data
app = FastAPI()
# Generate SSL Key and Certificate if not exist
Path("ssl").mkdir(parents=True, exist_ok=True)
if not Path("ssl/cert.pem").exists() or not Path("ssl/key.pem").exists():
ssl_heidi.generate_self_signed_cert("ssl/cert.pem", "ssl/key.pem")
# Determine environment and configuration file path
environment = os.getenv('ENVIRONMENT', 'dev')
config_file = Path(__file__).resolve().parent.parent / f'config_{environment}.json'
# Load configuration
with open(config_file) as f:
config = json.load(f)
cert_path = config['ssl_cert_path']
key_path = config['ssl_key_path']
# Generate SSL Key and Certificate if not exist (only for development)
if environment == 'development':
Path("ssl").mkdir(parents=True, exist_ok=True)
if not Path(cert_path).exists() or not Path(key_path).exists():
ssl_heidi.generate_self_signed_cert(cert_path, key_path)
# Apply CORS middleware
app.add_middleware(
@ -52,7 +67,20 @@ app.include_router(sample.router, prefix="/samples", tags=["samples"])
if __name__ == "__main__":
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(certfile="ssl/cert.pem", keyfile="ssl/key.pem")
import uvicorn
import os
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="debug", ssl_context=ssl_context)
# Get environment from an environment variable
environment = os.getenv('ENVIRONMENT', 'dev')
# Paths for SSL certificates
cert_path = "ssl/cert.pem"
key_path = "ssl/key.pem"
if environment == 'testing':
cert_path = "ssl/mx-aare-test.psi.ch.pem"
key_path = "ssl/mx-aare-test.psi.ch.key"
# Run the application with appropriate SSL setup
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="debug",
ssl_keyfile=key_path, ssl_certfile=cert_path)

9
config_dev.json Normal file
View File

@ -0,0 +1,9 @@
{
"ssl_cert_path": "ssl/cert.pem",
"ssl_key_path": "ssl/key.pem",
"OPENAPI_URL": "https://127.0.0.1:8000/openapi.json",
"SCHEMA_PATH": "./src/openapi.json",
"OUTPUT_DIRECTORY": "./openapi",
"SSL_KEY_PATH": "../backend/ssl/key.pem",
"SSL_CERT_PATH": "../backend/ssl/cert.pem"
}

9
config_test.json Normal file
View File

@ -0,0 +1,9 @@
{
"ssl_cert_path": "ssl/mx-aare-test.psi.ch.pem",
"ssl_key_path": "ss/mx-aare-test.psi.ch.key",
"OPENAPI_URL": "https://mx-aare-test.psi.ch:8000/openapi.json",
"SCHEMA_PATH": "./src/openapi.json",
"OUTPUT_DIRECTORY": "./openapi",
"SSL_KEY_PATH": "/home/jungfrau/heidi-v2/backend/app/ssl/mx-aare-test.psi.ch.key",
"SSL_CERT_PATH": "/home/jungfrau/heidi-v2/backend/app/ssl/mx-aare-test.psi.ch.pem"
}

2
frontend/.env Normal file
View File

@ -0,0 +1,2 @@
VITE_OPENAPI_BASE_DEV=https://127.0.0.1:8000
VITE_OPENAPI_BASE_TEST=https://mx-aare-test.psi.ch:8000

View File

@ -1,16 +1,28 @@
// fetch-and-generate-openapi.js
import fs from 'fs';
import https from 'https'; // Use https instead of http
import https from 'https';
import { exec } from 'child_process';
import chokidar from 'chokidar';
import path from 'path';
import util from 'util';
const OPENAPI_URL = 'https://127.0.0.1:8000/openapi.json';
const SCHEMA_PATH = path.resolve('./src/openapi.json');
const OUTPUT_DIRECTORY = path.resolve('./openapi');
const SSL_KEY_PATH = path.resolve('../backend/ssl/key.pem'); // Path to SSL key
const SSL_CERT_PATH = path.resolve('../backend/ssl/cert.pem'); // Path to SSL certificate
// Determine the environment
const environment = process.env.NODE_ENV || 'development';
const configFile = `config_${environment}.json`;
// Load the appropriate configuration
let config;
try {
config = JSON.parse(fs.readFileSync(path.resolve('../', configFile), 'utf8'));
} catch (error) {
console.error(`❌ Error reading configuration file: ${error.message}`);
process.exit(1);
}
const OPENAPI_URL = config.OPENAPI_URL;
const SCHEMA_PATH = path.resolve(config.SCHEMA_PATH);
const OUTPUT_DIRECTORY = path.resolve(config.OUTPUT_DIRECTORY);
const SSL_KEY_PATH = path.resolve(config.SSL_KEY_PATH);
const SSL_CERT_PATH = path.resolve(config.SSL_CERT_PATH);
console.log(`Using SCHEMA_PATH: ${SCHEMA_PATH}`);
console.log(`Using OUTPUT_DIRECTORY: ${OUTPUT_DIRECTORY}`);
@ -70,7 +82,6 @@ async function fetchAndGenerate() {
await fs.promises.rm(OUTPUT_DIRECTORY, { recursive: true, force: true });
console.log(`✅ Output directory cleaned at ${OUTPUT_DIRECTORY}`);
// Verify directory removal
if (fs.existsSync(OUTPUT_DIRECTORY)) {
console.error(`❌ Output directory still exists: ${OUTPUT_DIRECTORY}`);
} else {
@ -81,7 +92,6 @@ async function fetchAndGenerate() {
console.log(`Executing debug command: ${command}`);
const { stdout, stderr } = await execPromisified(command);
console.log("🔍 Inside exec callback");
if (stderr) {
console.error(`⚠️ stderr while generating services: ${stderr}`);
} else {
@ -99,13 +109,13 @@ async function fetchAndGenerate() {
}
}
const backendDirectory = '/Users/gotthardg/PycharmProjects/heidi-v2/backend/app';
const backendDirectory = path.resolve('/Users/gotthardg/PycharmProjects/heidi-v2/backend/app');
console.log(`👀 Watching for changes in ${backendDirectory}`);
const watcher = chokidar.watch(backendDirectory, { persistent: true, ignored: [SCHEMA_PATH, OUTPUT_DIRECTORY] });
watcher
.on('add', debounce(fetchAndGenerate, debounceDelay))
.on('change', debounce(fetchAndGenerate, debounceDelay)) // Corrected typo here
.on('change', debounce(fetchAndGenerate, debounceDelay))
.on('unlink', debounce(fetchAndGenerate, debounceDelay));
console.log(`👀 Watching for changes in ${backendDirectory}`);

View File

@ -24,6 +24,7 @@
"axios": "^1.7.7",
"chokidar": "^4.0.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"openapi-typescript-codegen": "^0.29.0",
@ -41,6 +42,7 @@
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"cross-env": "^7.0.3",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",
@ -3434,6 +3436,25 @@
"node": ">= 10"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
@ -3560,6 +3581,18 @@
"tslib": "^2.0.3"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",

View File

@ -4,12 +4,14 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "node_modules/.bin/vite",
"build": "node_modules/.bin/tsc -b && vite build",
"lint": "node_modules/.bin/eslint .",
"preview": "node_modules/.bin/vite preview",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"start-dev": "cross-env NODE_ENV=dev vite",
"start-test": "cross-env NODE_ENV=test vite",
"watch:openapi": "node fetch-openapi.js"
},
},
"dependencies": {
"@aldabil/react-scheduler": "^2.9.5",
"@bitnoi.se/react-scheduler": "^0.3.1",
@ -27,6 +29,7 @@
"axios": "^1.7.7",
"chokidar": "^4.0.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"openapi-typescript-codegen": "^0.29.0",
@ -44,6 +47,7 @@
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"cross-env": "^7.0.3",
"eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12",

View File

@ -39,7 +39,10 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ sx = {}, onCancel, refreshS
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
useEffect(() => {
OpenAPI.BASE = 'https://127.0.0.1:8000';
const isTestEnv = import.meta.env.MODE === 'test';
OpenAPI.BASE = isTestEnv
? import.meta.env.VITE_OPENAPI_BASE_TEST
: import.meta.env.VITE_OPENAPI_BASE_DEV;
const getContacts = async () => {
try {

View File

@ -1,14 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
IconButton,
Box,
CircularProgress
Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, IconButton, Box, CircularProgress
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import DownloadIcon from '@mui/icons-material/Download';
@ -17,45 +9,39 @@ import logo from '../assets/Heidi-logo.png';
import { OpenAPI, SpreadsheetService } from '../../openapi';
import type { Body_upload_file_upload_post } from '../../openapi/models/Body_upload_file_upload_post';
import SpreadsheetTable from './SpreadsheetTable';
import Modal from './Modal'; // Ensure correct import paths
import Modal from './Modal';
import * as ExcelJS from 'exceljs';
interface UploadDialogProps {
open: boolean;
onClose: () => void;
selectedShipment: any; // Adjust the type based on your implementation
selectedShipment: any;
}
const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, selectedShipment }) => {
const [uploadError, setUploadError] = useState<string | null>(null);
const [fileSummary, setFileSummary] = useState<{
data: any[];
errors: { row: number, cell: number, value: any, message: string }[];
raw_data: { row_num: number, data: any[] }[];
dewars_count: number;
dewars: string[];
pucks_count: number;
pucks: string[];
samples_count: number;
samples: string[];
headers: string[];
} | null>(null);
const [fileBlob, setFileBlob] = useState<Blob | null>(null); // New state to store the file blob
const [fileSummary, setFileSummary] = useState<any>(null);
const [fileBlob, setFileBlob] = useState<Blob | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
OpenAPI.BASE = 'https://127.0.0.1:8000';
const isTestEnv = import.meta.env.MODE === 'test';
OpenAPI.BASE = isTestEnv
? import.meta.env.VITE_OPENAPI_BASE_TEST
: import.meta.env.VITE_OPENAPI_BASE_DEV;
}, []);
const downloadUrl = `${OpenAPI.BASE}/download-template`;
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploadError(null);
setFileSummary(null);
setFileBlob(file); // Store the file blob
setFileBlob(file);
setIsLoading(true);
if (!file.name.endsWith('.xlsx')) {
@ -68,8 +54,8 @@ const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, selectedShip
try {
const response = await SpreadsheetService.uploadFileUploadPost(formData);
const { headers, raw_data, errors } = response;
setFileSummary({
data: raw_data,
errors: errors,
@ -82,6 +68,7 @@ const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, selectedShip
samples_count: 23,
samples: ['Sample1', 'Sample2']
});
setIsLoading(false);
setIsModalOpen(true);
} catch (error) {
@ -115,7 +102,7 @@ const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, selectedShip
<img src={logo} alt="Logo" style={{ width: 200, marginBottom: 16 }} />
<Typography variant="subtitle1">Latest Spreadsheet Template Version 7</Typography>
<Typography variant="body2" color="textSecondary">Last update: November 7, 2024</Typography>
<Button variant="outlined" startIcon={<DownloadIcon />} href="http://127.0.0.1:8000/download-template" download sx={{ mt: 1 }}>
<Button variant="outlined" startIcon={<DownloadIcon />} href={downloadUrl} download sx={{ mt: 1 }}>
Download XLSX
</Button>
<Typography variant="subtitle1" sx={{ mt: 3 }}>Latest Spreadsheet Instructions Version 2.3</Typography>
@ -165,8 +152,8 @@ const UploadDialog: React.FC<UploadDialogProps> = ({ open, onClose, selectedShip
headers={fileSummary.headers}
setRawData={(newRawData) => setFileSummary((prevSummary) => ({ ...prevSummary, raw_data: newRawData }))}
onCancel={handleCancel}
fileBlob={fileBlob} // Pass the original file blob
selectedShipment={selectedShipment} // Pass the selected shipment ID
fileBlob={fileBlob}
selectedShipment={selectedShipment}
/>
</Modal>
)}

View File

@ -6,12 +6,8 @@ import { Dewar, OpenAPI, Shipment } from '../../openapi';
import useShipments from '../hooks/useShipments';
import { Grid, Container } from '@mui/material';
// Define props for Shipments View
type ShipmentViewProps = React.PropsWithChildren<Record<string, never>>;
const API_BASE_URL = 'https://127.0.0.1:8000';
OpenAPI.BASE = API_BASE_URL;
const ShipmentView: React.FC<ShipmentViewProps> = () => {
const { shipments, error, defaultContactPerson, fetchAndSetShipments } = useShipments();
const [selectedShipment, setSelectedShipment] = useState<Shipment | null>(null);
@ -19,10 +15,13 @@ const ShipmentView: React.FC<ShipmentViewProps> = () => {
const [isCreatingShipment, setIsCreatingShipment] = useState(false);
useEffect(() => {
console.log('Updated shipments:', shipments);
}, [shipments]);
const isTestEnv = import.meta.env.MODE === 'test';
OpenAPI.BASE = isTestEnv
? import.meta.env.VITE_OPENAPI_BASE_TEST
: import.meta.env.VITE_OPENAPI_BASE_DEV;
fetchAndSetShipments();
}, []);
// Handlers for selecting shipment and canceling form
const handleSelectShipment = (shipment: Shipment | null) => {
setSelectedShipment(shipment);
setIsCreatingShipment(false);
@ -32,7 +31,6 @@ const ShipmentView: React.FC<ShipmentViewProps> = () => {
setIsCreatingShipment(false);
};
// Render the shipment content based on state
const renderShipmentContent = () => {
if (isCreatingShipment) {
return (
@ -60,7 +58,6 @@ const ShipmentView: React.FC<ShipmentViewProps> = () => {
return <div>No shipment details available.</div>;
};
// Render the main layout using Grid for layout
return (
<Container maxWidth={false} disableGutters sx={{ display: 'flex', height: '100vh' }}>
<Grid container spacing={2} sx={{ height: '100vh' }}>

12
logistics/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
logistics/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

12
logistics/src/App.tsx Normal file
View File

@ -0,0 +1,12 @@
import LogisticsView from './pages/LogisticsView';
import './App.css';
function App() {
return (
<div className="App">
<LogisticsView />
</div>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,49 @@
import React from 'react';
import { Modal as MuiModal, Box, Typography, IconButton } from '@mui/material';
import { Close } from '@mui/icons-material';
import styled from 'styled-components';
const ModalContent = styled(Box)`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
width: 80%;
max-width: 600px;
`;
const ModalHeader = styled(Box)`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
`;
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ open, onClose, title, children }) => {
return (
<MuiModal open={open} onClose={onClose}>
<ModalContent>
<ModalHeader>
<Typography variant="h6">{title}</Typography>
<IconButton onClick={onClose}>
<Close />
</IconButton>
</ModalHeader>
{children}
</ModalContent>
</MuiModal>
);
};
export default Modal;

6
logistics/src/index.css Normal file
View File

@ -0,0 +1,6 @@
/* index.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

10
logistics/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,533 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Button, TextField, Typography, Grid, IconButton, Snackbar, Alert } from '@mui/material';
import { CameraAlt } from '@mui/icons-material';
import ScannerModal from '../components/ScannerModal';
import Storage from '../components/Storage';
import { LogisticsService, OpenAPI } from '../../../frontend/openapi';
import type { Dewar, Slot as SlotSchema } from '../../../frontend/openapi/models';
import styled from 'styled-components';
import moment from 'moment';
import { format } from 'date-fns';
const slotQRCodes = [
"A1-X06SA", "A2-X06SA", "A3-X06SA", "A4-X06SA", "A5-X06SA",
"B1-X06SA", "B2-X06SA", "B3-X06SA", "B4-X06SA", "B5-X06SA",
"C1-X06SA", "C2-X06SA", "C3-X06SA", "C4-X06SA", "C5-X06SA",
"D1-X06SA", "D2-X06SA", "D3-X06SA", "D4-X06SA", "D5-X06SA",
"A1-X10SA", "A2-X10SA", "A3-X10SA", "A4-X10SA", "A5-X10SA",
"B1-X10SA", "B2-X10SA", "B3-X10SA", "B4-X10SA", "B5-X10SA",
"C1-X10SA", "C2-X10SA", "C3-X10SA", "C4-X10SA", "C5-X10SA",
"D1-X10SA", "D2-X10SA", "D3-X10SA", "D4-X10SA", "D5-X10SA",
"NB1", "NB2", "NB3", "NB4", "NB5", "NB6"
];
const beamlineQRCodes = [
"X10SA-Beamline", "X06SA-Beamline", "X06DA-Beamline"
];
const outgoingQRCodes = [
"Outgoing X10SA", "Outgoing X06SA"
];
const storageToSlotsMapping = {
"X06SA-storage": [
"A1-X06SA", "A2-X06SA", "A3-X06SA", "A4-X06SA", "A5-X06SA",
"B1-X06SA", "B2-X06SA", "B3-X06SA", "B4-X06SA", "B5-X06SA",
"C1-X06SA", "C2-X06SA", "C3-X06SA", "C4-X06SA", "C5-X06SA",
"D1-X06SA", "D2-X06SA", "D3-X06SA", "D4-X06SA", "D5-X06SA"
],
"X10SA-storage": [
"A1-X10SA", "A2-X10SA", "A3-X10SA", "A4-X10SA", "A5-X10SA",
"B1-X10SA", "B2-X10SA", "B3-X10SA", "B4-X10SA", "B5-X10SA",
"C1-X10SA", "C2-X10SA", "C3-X10SA", "C4-X10SA", "C5-X10SA",
"D1-X10SA", "D2-X10SA", "D3-X10SA", "D4-X10SA", "D5-X10SA"
],
"Novartis-Box": ["NB1", "NB2", "NB3", "NB4", "NB5", "NB6"]
};
interface SlotData extends SlotSchema {
dewar: Dewar | null;
qr_code: string;
dewar_name?: string;
needsRefillWarning?: boolean;
retrievedTimestamp?: string;
beamlineLocation?: string;
shipment_name?: string;
contact_person?: string;
local_contact?: string;
}
const DetailPanel = styled(Box)`
width: 100%;
padding: 20px;
background-color: white;
margin-top: 20px;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
`;
const LogisticsView: React.FC = () => {
const [dewarQr, setDewarQr] = useState<string | null>(null);
const [locationQr, setLocationQr] = useState<string | null>(null);
const [transactionType, setTransactionType] = useState<string>("");
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
const [sslError, setSslError] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [slotsData, setSlotsData] = useState<SlotData[]>([]);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const [selectedSlotData, setSelectedSlotData] = useState<SlotData | null>(null);
const [retrievedDewar, setRetrievedDewar] = useState<string | null>(null);
const formatTimestamp = (timestamp: string | undefined) => {
if (!timestamp) return 'N/A';
const date = new Date(timestamp);
return format(date, 'PPpp', { addSuffix: true });
};
// Reference to the audio element
const audioRef = useRef<HTMLAudioElement | null>(null);
OpenAPI.BASE = 'https://localhost:8000';
const fetchDewarsAndSlots = async () => {
try {
console.log("Fetching dewars and slots...");
setLoading(true);
setError(null);
setWarningMessage(null);
const [dewars, slots] = await Promise.all([
LogisticsService.getAllDewarsLogisticsDewarsGet(),
LogisticsService.getAllSlotsLogisticsSlotsGet(),
]);
console.log("Dewars fetched:", dewars);
console.log("Slots fetched:", slots);
const dewarMap: { [key: string]: Dewar } = {};
const usedDewarUniqueIds = new Map<string, string>();
dewars.forEach((dewar) => {
if (dewar.unique_id) {
dewarMap[dewar.unique_id] = dewar;
console.log(`Dewar ID: ${dewar.unique_id}`);
}
});
const newSlotsData = slots.map((slot) => {
let associatedDewar: Dewar | undefined;
if (slot.dewar_unique_id) {
if (usedDewarUniqueIds.has(slot.dewar_unique_id)) {
const existingSlotId = usedDewarUniqueIds.get(slot.dewar_unique_id);
console.warn(`Dewar with unique ID ${slot.dewar_unique_id} is already assigned to slot ${existingSlotId}`);
setWarningMessage(`Dewar ${slot.dewar_unique_id} is already assigned to slot ${existingSlotId}`);
return {
...slot,
occupied: false,
dewar: null,
time_until_refill: undefined,
needsRefillWarning: true,
beamlineLocation: undefined,
shipmnet_name: undefined,
contact_person: undefined,
local_contact: undefined,
};
} else {
associatedDewar = dewarMap[slot.dewar_unique_id];
if (associatedDewar) {
usedDewarUniqueIds.set(slot.dewar_unique_id, slot.id);
}
}
}
return {
...slot,
occupied: !!associatedDewar,
dewar: associatedDewar || null,
dewar_name: associatedDewar ? associatedDewar.dewar_name : undefined,
needsRefillWarning: !associatedDewar || slot.time_until_refill === undefined,
beamlineLocation: slot.beamlineLocation,
shipment_name: slot.shipment_name,
contact_person: slot.contact_person,
local_contact: slot.local_contact,
};
});
setSlotsData(newSlotsData);
} catch (e) {
console.error(e);
setError('Failed to fetch data.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDewarsAndSlots();
}, []);
const handleSlotSelection = async (data: { text: string }) => {
const scannedText = data.text.trim();
if (beamlineQRCodes.includes(scannedText)) {
if (dewarQr || selectedSlot) {
const dewarId = dewarQr || slotsData.find(slot => slot.qr_code === selectedSlot)?.dewar?.unique_id;
if (dewarId) {
try {
const timestamp = moment().toISOString();
await LogisticsService.scanDewarLogisticsDewarScanPost({
dewar_qr_code: dewarId,
location_qr_code: scannedText,
transaction_type: 'beamline',
timestamp: timestamp,
});
fetchDewarsAndSlots();
setWarningMessage(`Dewar ${dewarId} assigned to beamline.`);
} catch (e) {
console.error(e);
setError('Error updating dewar status at beamline.');
}
}
} else {
fetchDewarAndAssociate(scannedText);
}
return;
}
if (outgoingQRCodes.includes(scannedText)) {
setDewarQr(scannedText);
handleOutgoing();
return;
}
if (slotQRCodes.includes(scannedText)) {
const slot = slotsData.find(slot => slot.qr_code === scannedText);
setLocationQr(scannedText);
setSelectedSlot(scannedText);
if (slot?.dewar?.unique_id) {
console.log(`Returning dewar ${slot.dewar.unique_id} to slot ${scannedText}`);
await returnDewarToStorage(slot.dewar.unique_id, scannedText);
} else {
fetchDewarAndAssociate(scannedText);
}
} else {
fetchDewarAndAssociate(scannedText);
}
};
const returnDewarToStorage = async (dewarId: string, slotQrCode: string) => {
const payload = {
dewar_qr_code: dewarId,
location_qr_code: slotQrCode,
transaction_type: "returned",
};
console.log("Sending payload:", payload);
console.log(`Dewar ID: ${dewarId}, Slot QR Code: ${slotQrCode}`);
try {
await LogisticsService.returnToStorageLogisticsDewarsReturnPost(payload);
fetchDewarsAndSlots();
alert(`Dewar ${dewarId} successfully returned to storage.`);
} catch (error) {
console.error('Failed to return dewar to storage:', error);
if (error.status === 400 && error.response?.data?.detail === "Selected slot is already occupied") {
alert('Selected slot is already occupied. Please choose a different slot.');
} else {
console.error('Unexpected error occurred:', error);
alert('Failed to return dewar to storage.');
}
setError('Failed to return dewar to storage.');
}
};
const handleSlotSelect = (slot: SlotData) => {
if (selectedSlot === slot.qr_code) {
setSelectedSlot(null);
setLocationQr(null);
setSelectedSlotData(null);
} else {
setSelectedSlot(slot.qr_code);
setLocationQr(slot.qr_code);
setSelectedSlotData(slot);
setRetrievedDewar(slot.dewar?.unique_id || null);
}
};
const fetchDewarAndAssociate = async (scannedText: string) => {
try {
const dewar = await LogisticsService.getDewarByUniqueIdLogisticsDewarUniqueIdGet(scannedText);
setDewarQr(dewar.unique_id);
if (audioRef.current) {
audioRef.current.play();
}
} catch (e) {
console.error(e);
if (e.message.includes('SSL')) {
setSslError(true);
} else {
alert('No dewar found with this QR code.');
}
}
};
const handleRefillDewar = async (qrCode?: string) => {
const dewarUniqueId = qrCode || slotsData.find(slot => slot.qr_code === selectedSlot)?.dewar?.unique_id;
if (!dewarUniqueId) {
alert('No dewar associated with the selected slot.');
return;
}
try {
const trimmedUniqueId = dewarUniqueId.trim();
const response = await LogisticsService.refillDewarLogisticsDewarRefillPost(trimmedUniqueId);
if (response && response.time_until_refill) {
alert(`Dewar refilled successfully. Time until next refill: ${response.time_until_refill}`);
} else {
alert('Dewar refilled successfully.');
}
fetchDewarsAndSlots();
} catch (e) {
console.error(e);
alert('Error in refilling dewar');
}
};
const handleSubmit = async () => {
if (!dewarQr || !locationQr || !transactionType) {
alert('All fields are required.');
return;
}
if (!dewarQr.trim()) {
alert('Dewar QR code should not be empty.');
return;
}
const conflictingSlots = slotsData.filter(
slot => slot.dewar?.unique_id === dewarQr && slot.qr_code !== locationQr
);
if (conflictingSlots.length > 0) {
alert(`Dewar ${dewarQr} is already assigned to slot ${conflictingSlots[0].qr_code}. Please resolve the conflict first.`);
return;
}
try {
const timestamp = moment().toISOString();
await LogisticsService.scanDewarLogisticsDewarScanPost({
dewar_qr_code: dewarQr.trim(),
location_qr_code: locationQr.trim(),
transaction_type: transactionType,
timestamp: timestamp,
});
alert('Dewar status updated successfully');
if (audioRef.current) {
audioRef.current.play();
}
fetchDewarsAndSlots();
} catch (e) {
console.error(e);
alert('Error updating dewar status');
}
};
const handleRetrieve = async () => {
if (!retrievedDewar) {
alert('No dewar selected for retrieval.');
return;
}
try {
await LogisticsService.retrieveDewarLogisticsDewarsRetrievePost({
dewar_qr_code: retrievedDewar,
location_qr_code: '',
transaction_type: 'retrieved',
});
alert(`Dewar ${retrievedDewar} retrieved successfully.`);
setRetrievedDewar(null);
fetchDewarsAndSlots();
} catch (e) {
console.error(e);
alert('Error retrieving dewar');
}
};
const handleOutgoing = async () => {
if (!dewarQr) {
alert('Scan a dewar QR code first.');
return;
}
try {
await LogisticsService.scanDewarLogisticsDewarScanPost({
dewar_qr_code: dewarQr,
location_qr_code: dewarQr, // Using dewar QR code as location for outgoing
transaction_type: 'outgoing',
timestamp: moment().toISOString(),
});
alert(`Dewar ${dewarQr} is now marked as outgoing.`);
fetchDewarsAndSlots();
} catch (e) {
console.error(e);
alert('Error marking dewar as outgoing');
}
};
return (
<Box>
<Typography variant="h4" gutterBottom>
Logistics Tracking
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Box display="flex" flexDirection="column" alignItems="flex-start">
<Typography variant="h6">Dewar QR Code</Typography>
<TextField
label="Enter Dewar QR Code"
variant="outlined"
fullWidth
value={dewarQr ?? ''}
onChange={(e) => setDewarQr(e.target.value)}
sx={{ mb: 2 }}
/>
<IconButton onClick={() => setIsModalOpen(true)} sx={{ fontSize: 40, mb: 2 }}>
<CameraAlt fontSize="inherit" />
</IconButton>
<Typography variant="h6">Selected Slot</Typography>
<TextField
label="Slot QR Code"
variant="outlined"
fullWidth
value={locationQr ?? ''}
onChange={(e) => setLocationQr(e.target.value)}
sx={{ mb: 2 }}
disabled
/>
<Button
variant="contained"
onClick={() => setTransactionType('incoming')}
color={transactionType === 'incoming' ? 'primary' : 'default'}
sx={{ mb: 1 }}
>
Incoming
</Button>
<Button
variant="contained"
onClick={() => setTransactionType('outgoing')}
color={transactionType === 'outgoing' ? 'primary' : 'default'}
sx={{ mb: 2 }}
>
Outgoing
</Button>
<Button variant="contained" color="primary" onClick={handleSubmit} sx={{ mb: 2 }}>
Submit
</Button>
<Button variant="contained" color="secondary" onClick={() => handleRefillDewar()}>
Refill Dewar
</Button>
<Button variant="outlined" color="warning" onClick={handleRetrieve} sx={{ mb: 2 }}>
Retrieved
</Button>
</Box>
</Grid>
<Grid item xs={12} sm={6}>
{selectedSlotData ? (
<Grid item xs={12} sm={6}>
{selectedSlotData ? (
<DetailPanel>
<Typography variant="h6">{selectedSlotData.label}</Typography>
<Typography variant="body2">{`Shipment: ${selectedSlotData.shipment_name}`}</Typography>
<Typography variant="body2">{`Dewar: ${selectedSlotData.dewar_name || 'N/A'}`}</Typography>
<Typography variant="body2">{`Contact Person: ${selectedSlotData.contact_person}`}</Typography>
<Typography variant="body2">{`QR Code: ${selectedSlotData.qr_code}`}</Typography>
<Typography variant="body2">{`Occupied: ${selectedSlotData.occupied ? 'Yes' : 'No'}`}</Typography>
<Typography variant="body2">{`Needs Refill: ${selectedSlotData.needsRefillWarning ? 'Yes' : 'No'}`}</Typography>
<Typography variant="body2">{`Time Until Refill: ${selectedSlotData.time_until_refill ?? 'N/A'}`}</Typography>
<Typography variant="body2">{`Retrieved Timestamp: ${formatTimestamp(selectedSlotData.retrievedTimestamp)}`}</Typography>
<Typography variant="body2">{`Local Contact: ${selectedSlotData.local_contact}`}</Typography>
<Typography variant="body2">{`Beamline Location: ${selectedSlotData.beamlineLocation || 'N/A'}`}</Typography>
</DetailPanel>
) : (
<Typography variant="h6">Select a slot to see more details.</Typography>
)}
</Grid>
) : (
<Typography>No slot selected</Typography>
)}
</Grid>
</Grid>
{loading ? (
<Typography mt={2}>Loading...</Typography>
) : error ? (
<Typography mt={2} color="error">{error}</Typography>
) : (
<Box>
{['X06SA-storage', 'X10SA-storage', 'Novartis-Box'].map((storageKey) => {
const filteredSlots = slotsData.filter((slot) => storageToSlotsMapping[storageKey].includes(slot.qr_code));
return (
<Storage
key={storageKey}
name={storageKey}
selectedSlot={selectedSlot}
slotsData={filteredSlots}
onSelectSlot={handleSlotSelect}
onRefillDewar={handleRefillDewar}
/>
);
})}
</Box>
)}
<ScannerModal open={isModalOpen} onClose={() => setIsModalOpen(false)} onScan={handleSlotSelection} />
<Snackbar open={sslError} autoHideDuration={6000} onClose={() => setSslError(false)}>
<Alert onClose={() => setSslError(false)} severity="error">
SSL Error: Unable to establish a secure connection with the server.
</Alert>
</Snackbar>
{warningMessage && (
<Snackbar open autoHideDuration={6000} onClose={() => setWarningMessage(null)}>
<Alert severity="warning" onClose={() => setWarningMessage(null)}>
{warningMessage}
</Alert>
</Snackbar>
)}
{error && (
<Snackbar open autoHideDuration={6000} onClose={() => setError(null)}>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
)}
<audio ref={audioRef} src="src/assets/50565__broumbroum__sf3-sfx-menu-validate.wav" preload="auto" />
</Box>
);
};
export default LogisticsView;

1
logistics/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />