diff --git a/config_dev.json b/config_dev.json index 9952169..0ac3c26 100644 --- a/config_dev.json +++ b/config_dev.json @@ -1,4 +1,9 @@ { "ssl_cert_path": "ssl/cert.pem", - "ssl_key_path": "ssl/key.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" } \ No newline at end of file diff --git a/config_test.json b/config_test.json index bc9d5c2..1bfd8b1 100644 --- a/config_test.json +++ b/config_test.json @@ -1,4 +1,9 @@ { "ssl_cert_path": "ssl/mx-aare-test.psi.ch.pem", - "ssl_key_path": "ss/mx-aare-test.psi.ch.key" + "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" } \ No newline at end of file diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..d6b27e2 --- /dev/null +++ b/frontend/.env @@ -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 \ No newline at end of file diff --git a/frontend/fetch-openapi.js b/frontend/fetch-openapi.js index 87732ae..86a0ae4 100644 --- a/frontend/fetch-openapi.js +++ b/frontend/fetch-openapi.js @@ -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}`); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3cee4b0..e2f9304 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index abb1a5e..b71e77b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/ShipmentForm.tsx b/frontend/src/components/ShipmentForm.tsx index ba2b56b..b837ad4 100644 --- a/frontend/src/components/ShipmentForm.tsx +++ b/frontend/src/components/ShipmentForm.tsx @@ -39,7 +39,10 @@ const ShipmentForm: React.FC = ({ sx = {}, onCancel, refreshS const [errorMessage, setErrorMessage] = React.useState(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 { diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index dd9f3cb..7189a1e 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -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 = ({ open, onClose, selectedShipment }) => { const [uploadError, setUploadError] = useState(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(null); // New state to store the file blob + const [fileSummary, setFileSummary] = useState(null); + const [fileBlob, setFileBlob] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const fileInputRef = useRef(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) => { 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 = ({ 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 = ({ open, onClose, selectedShip samples_count: 23, samples: ['Sample1', 'Sample2'] }); + setIsLoading(false); setIsModalOpen(true); } catch (error) { @@ -115,7 +102,7 @@ const UploadDialog: React.FC = ({ open, onClose, selectedShip Logo Latest Spreadsheet Template Version 7 Last update: November 7, 2024 - Latest Spreadsheet Instructions Version 2.3 @@ -165,8 +152,8 @@ const UploadDialog: React.FC = ({ 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} /> )} diff --git a/frontend/src/pages/ShipmentView.tsx b/frontend/src/pages/ShipmentView.tsx index 6265107..9e3b7a0 100644 --- a/frontend/src/pages/ShipmentView.tsx +++ b/frontend/src/pages/ShipmentView.tsx @@ -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>; -const API_BASE_URL = 'https://127.0.0.1:8000'; -OpenAPI.BASE = API_BASE_URL; - const ShipmentView: React.FC = () => { const { shipments, error, defaultContactPerson, fetchAndSetShipments } = useShipments(); const [selectedShipment, setSelectedShipment] = useState(null); @@ -19,10 +15,13 @@ const ShipmentView: React.FC = () => { 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 = () => { setIsCreatingShipment(false); }; - // Render the shipment content based on state const renderShipmentContent = () => { if (isCreatingShipment) { return ( @@ -60,7 +58,6 @@ const ShipmentView: React.FC = () => { return
No shipment details available.
; }; - // Render the main layout using Grid for layout return ( diff --git a/logistics/index.html b/logistics/index.html new file mode 100644 index 0000000..140929d --- /dev/null +++ b/logistics/index.html @@ -0,0 +1,12 @@ + + + + + + Vite + React + + +
+ + + \ No newline at end of file diff --git a/logistics/public/vite.svg b/logistics/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/logistics/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logistics/src/App.css b/logistics/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/logistics/src/App.css @@ -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; +} diff --git a/logistics/src/App.tsx b/logistics/src/App.tsx new file mode 100644 index 0000000..e7e38dd --- /dev/null +++ b/logistics/src/App.tsx @@ -0,0 +1,12 @@ +import LogisticsView from './pages/LogisticsView'; +import './App.css'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; \ No newline at end of file diff --git a/logistics/src/assets/react.svg b/logistics/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/logistics/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logistics/src/components/Modal.tsx b/logistics/src/components/Modal.tsx new file mode 100644 index 0000000..ee57eb7 --- /dev/null +++ b/logistics/src/components/Modal.tsx @@ -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 = ({ open, onClose, title, children }) => { + return ( + + + + {title} + + + + + {children} + + + ); +}; + +export default Modal; \ No newline at end of file diff --git a/logistics/src/index.css b/logistics/src/index.css new file mode 100644 index 0000000..5e96e1f --- /dev/null +++ b/logistics/src/index.css @@ -0,0 +1,6 @@ +/* index.css */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} \ No newline at end of file diff --git a/logistics/src/main.tsx b/logistics/src/main.tsx new file mode 100644 index 0000000..ae4776a --- /dev/null +++ b/logistics/src/main.tsx @@ -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( + + + +); \ No newline at end of file diff --git a/logistics/src/pages/LogisticsView.tsx b/logistics/src/pages/LogisticsView.tsx new file mode 100644 index 0000000..73ccc13 --- /dev/null +++ b/logistics/src/pages/LogisticsView.tsx @@ -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(null); + const [locationQr, setLocationQr] = useState(null); + const [transactionType, setTransactionType] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedSlot, setSelectedSlot] = useState(null); + const [sslError, setSslError] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [slotsData, setSlotsData] = useState([]); + const [warningMessage, setWarningMessage] = useState(null); + const [selectedSlotData, setSelectedSlotData] = useState(null); + const [retrievedDewar, setRetrievedDewar] = useState(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(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(); + + 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 ( + + + Logistics Tracking + + + + + + Dewar QR Code + setDewarQr(e.target.value)} + sx={{ mb: 2 }} + /> + setIsModalOpen(true)} sx={{ fontSize: 40, mb: 2 }}> + + + + Selected Slot + setLocationQr(e.target.value)} + sx={{ mb: 2 }} + disabled + /> + + + + + + + + + + + + {selectedSlotData ? ( + + {selectedSlotData ? ( + + {selectedSlotData.label} + {`Shipment: ${selectedSlotData.shipment_name}`} + {`Dewar: ${selectedSlotData.dewar_name || 'N/A'}`} + {`Contact Person: ${selectedSlotData.contact_person}`} + {`QR Code: ${selectedSlotData.qr_code}`} + {`Occupied: ${selectedSlotData.occupied ? 'Yes' : 'No'}`} + {`Needs Refill: ${selectedSlotData.needsRefillWarning ? 'Yes' : 'No'}`} + {`Time Until Refill: ${selectedSlotData.time_until_refill ?? 'N/A'}`} + {`Retrieved Timestamp: ${formatTimestamp(selectedSlotData.retrievedTimestamp)}`} + {`Local Contact: ${selectedSlotData.local_contact}`} + {`Beamline Location: ${selectedSlotData.beamlineLocation || 'N/A'}`} + + ) : ( + Select a slot to see more details. + )} + + ) : ( + No slot selected + )} + + + + {loading ? ( + Loading... + ) : error ? ( + {error} + ) : ( + + {['X06SA-storage', 'X10SA-storage', 'Novartis-Box'].map((storageKey) => { + const filteredSlots = slotsData.filter((slot) => storageToSlotsMapping[storageKey].includes(slot.qr_code)); + return ( + + ); + })} + + )} + + setIsModalOpen(false)} onScan={handleSlotSelection} /> + + setSslError(false)}> + setSslError(false)} severity="error"> + SSL Error: Unable to establish a secure connection with the server. + + + + {warningMessage && ( + setWarningMessage(null)}> + setWarningMessage(null)}> + {warningMessage} + + + )} + + {error && ( + setError(null)}> + setError(null)}> + {error} + + + )} + + + ); +}; + +export default LogisticsView; \ No newline at end of file diff --git a/logistics/src/vite-env.d.ts b/logistics/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/logistics/src/vite-env.d.ts @@ -0,0 +1 @@ +///