aaredb/frontend/src/components/SpreadsheetTable.tsx
GotthardG c2215860bf Refactor Dewar service methods and improve field handling
Updated Dewar API methods to use protected endpoints for enhanced security and consistency. Added `pgroups` handling in various frontend components and modified the LogisticsView contact field for clarity. Simplified backend router imports for better readability.
2025-01-30 13:39:49 +01:00

685 lines
30 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Tooltip,
TextField,
Typography,
Button,
Box,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle
} from '@mui/material';
import { SpreadsheetService, ShipmentsService, DewarsService, ApiError } from '../../openapi';
import * as ExcelJS from 'exceljs';
import { saveAs } from 'file-saver';
const SpreadsheetTable = ({
raw_data,
errors,
headers,
setRawData,
onCancel,
fileBlob,
selectedShipment,
addinfo,
activePgroup,
}) => {
const [localErrors, setLocalErrors] = useState(errors || []);
const [editingCell, setEditingCell] = useState({});
const [nonEditableCells, setNonEditableCells] = useState(new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [dewarsToReplace, setDewarsToReplace] = useState([]);
const [dewarsToCreate, setDewarsToCreate] = useState(new Map());
const [correctionMetadata, setCorrectionMetadata] = useState(addinfo || []); // Store addinfo
const [errorDialogOpen, setErrorDialogOpen] = useState(false); // Controls dialog visibility
const [errorMessages, setErrorMessages] = useState<string[]>([]); // Error messages from the server
const enhancedRawData = raw_data.map((row) => {
const metadata = correctionMetadata.find((info) => info.row_num === row.row_num) || {};
// Combine original row data with metadata
return {
...row,
corrected_columns: metadata.corrected_columns || [], // Columns corrected
default_set_columns: metadata.default_set || [], // Specific columns default-assigned
};
});
useEffect(() => {
console.log("Correction Metadata:", correctionMetadata);
console.log("Addinfo:", addinfo);
}, [correctionMetadata, addinfo]);
const initialNewDewarState = {
pgroups: activePgroup,
number_of_pucks: 0,
number_of_samples: 0,
contact_id: selectedShipment?.contact?.id,
return_address_id: selectedShipment?.return_address?.id,
dewar_name: '',
tracking_number: 'UNKNOWN',
status: 'active',
pucks: [] // Ensure 'pucks' array exists
};
const [newDewar, setNewDewar] = useState(initialNewDewarState);
useEffect(() => {
setNewDewar((prev) => ({
...prev,
contact_id: selectedShipment?.contact?.id,
return_address_id: selectedShipment?.return_address?.id
}));
}, [selectedShipment]);
const generateErrorMap = (errorsList) => {
const errorMap = new Map();
if (Array.isArray(errorsList)) {
errorsList.forEach((error) => {
const colIndex = headers.findIndex(header => header === error.column);
if (colIndex > -1) {
const key = `${error.row}-${headers[colIndex]}`;
errorMap.set(key, error.message);
}
});
}
return errorMap;
};
const errorMap = generateErrorMap(localErrors);
useEffect(() => {
const updatedNonEditableCells = new Set();
raw_data.forEach((row, rowIndex) => {
headers.forEach((_, colIndex) => {
const key = `${row.row_num}-${headers[colIndex]}`;
if (!errorMap.has(key)) {
updatedNonEditableCells.add(`${rowIndex}-${colIndex}`);
}
});
});
setNonEditableCells(updatedNonEditableCells);
console.log("Recalculated nonEditableCells:", updatedNonEditableCells);
}, [raw_data, headers, errorMap]);
const handleCellEdit = async (rowIndex, colIndex) => {
const updatedRawData = [...raw_data];
const columnName = headers[colIndex];
const currentRow = updatedRawData[rowIndex];
const newValue = editingCell[`${rowIndex}-${colIndex}`];
if (newValue === undefined) return; // Ensure value is provided
// Prepare for validation request
if (!currentRow.data) {
currentRow.data = [];
}
currentRow.data[colIndex] = newValue;
// Reset editing state
setEditingCell((prev) => {
const updated = { ...prev };
delete updated[`${rowIndex}-${colIndex}`];
return updated;
});
try {
const response = await SpreadsheetService.validateCellValidateCellPost({
row: currentRow.row_num,
column: columnName,
value: newValue,
});
if (response && response.is_valid !== undefined) {
if (response.is_valid) {
const correctedValue = response.corrected_value ?? newValue;
currentRow.data[colIndex] = correctedValue;
updatedRawData[rowIndex] = currentRow;
setRawData(updatedRawData);
// Remove the error and mark as non-editable
const updatedErrors = localErrors.filter(
(error) =>
!(
error.row === currentRow.row_num &&
error.cell === colIndex &&
error.message.toLowerCase().includes("duplicate position")
)
);
setLocalErrors(updatedErrors); // Update error list
setNonEditableCells((prev) => new Set([...prev, `${rowIndex}-${colIndex}`]));
} else {
// If not valid, don't add to nonEditableCells and update the error list
const errorMessage = response.message || "Invalid value.";
const newError = {
row: currentRow.row_num,
cell: colIndex,
message: errorMessage,
};
const updatedErrors = [
...localErrors.filter(
(error) => !(error.row === newError.row && error.cell === newError.cell)
),
newError,
];
setLocalErrors(updatedErrors);
setNonEditableCells((prev) => {
const updatedSet = new Set(prev);
updatedSet.delete(`${rowIndex}-${colIndex}`); // Ensure it stays editable
return updatedSet;
});
}
} else {
console.error("Unexpected response from backend:", response);
}
} catch (error) {
console.error("Validation request failed:", error);
}
};
const handleCellBlur = (rowIndex, colIndex) => {
handleCellEdit(rowIndex, colIndex);
};
const allCellsValid = () =>
nonEditableCells.size === raw_data.length * headers.length &&
!localErrors.some((error) =>
error.message.toLowerCase().includes("duplicate position")
);
const fieldToCol = {
'dewarname': 0,
'puckname': 1,
'pucktype': 2,
'crystalname': 3,
'positioninpuck': 4,
'priority': 5,
'comments': 6,
'directory': 7,
'proteinname': 8,
'oscillation': 9,
'aperture': 10,
'exposure': 11,
'totalrange': 12,
'transmission': 13,
'dose': 14,
'targetresolution': 15,
'datacollectiontype': 16,
'processingpipeline': 17,
'spacegroupnumber': 18,
'cellparameters': 19,
'rescutkey': 20,
'rescutvalue': 21,
'userresolution': 22,
'pdbid': 23,
'autoprocfull': 24,
'procfull': 25,
'adpenabled': 26,
'noano': 27,
'ffcscampaign': 28,
'trustedhigh': 29,
'autoprocextraparams': 30,
'chiphiangles': 31
};
const checkIfDewarExists = async (dewarName: string) => {
if (!selectedShipment) return null;
try {
// Fetch dewars related to the current shipment via API
const shipDewars = await ShipmentsService.getDewarsByShipmentIdProtectedShipmentsShipmentIdDewarsGet(selectedShipment.id);
// Search for dewar by name within the shipment
const existingDewar = shipDewars.find((d) => d.dewar_name === dewarName);
if (existingDewar) {
console.log(`Dewar "${dewarName}" exists with ID: ${existingDewar.id}`);
return existingDewar;
}
return null;
} catch (error) {
console.error("Failed to fetch existing dewars:", error);
return null;
}
};
const createOrUpdateDewarsFromSheet = async (data, contact, address) => {
if (!contact?.id || !address?.id) {
console.error('contact_id or return_address_id is missing');
return null;
}
const dewars = new Map();
const puckPositionMap = new Map();
const dewarsToReplace = [];
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
if (!row.data) {
console.error(`Row ${rowIndex}: Missing or invalid data`);
continue;
}
const dewarName = sanitizeAndValidateColumn(row, fieldToCol['dewarname'], 'string', 'Dewar Name');
const puckName = sanitizeAndValidateColumn(row, fieldToCol['puckname'], 'string', 'Puck Name');
const puckType = sanitizeAndValidateColumn(row, fieldToCol['pucktype'], 'string', 'Unipuck', true); // Default: `Unipuck`
const sampleName = sanitizeAndValidateColumn(row, fieldToCol['crystalname'], 'string', 'Sample Name');
if (dewarName && puckName) {
let dewar;
if (!dewars.has(dewarName)) {
// Initialize new dewar object
dewar = {
...initialNewDewarState,
pgroups: activePgroup,
dewar_name: dewarName,
contact_id: contact.id,
return_address_id: address.id,
pucks: [],
};
dewars.set(dewarName, dewar);
puckPositionMap.set(dewarName, new Map());
// Check if dewar exists using backend
const existingDewar = await checkIfDewarExists(dewarName);
if (existingDewar) {
dewarsToReplace.push(existingDewar);
}
} else {
dewar = dewars.get(dewarName);
}
// Handle puck positions
let puckPositions = puckPositionMap.get(dewarName);
if (!puckPositions.has(puckName)) {
puckPositions.set(puckName, puckPositions.size + 1);
}
const puckPosition = puckPositions.get(puckName);
// Create puck and attach it to the dewar
let puck = dewar.pucks.find((p) => p.puck_name === puckName);
if (!puck) {
puck = { puck_name: puckName, puck_type: puckType, puck_location_in_dewar: puckPosition, samples: [] };
dewar.pucks.push(puck);
}
// Add sample to puck
const sample = {
sample_name: sampleName,
position: sanitizeIntColumn(row, fieldToCol['positioninpuck'], 'Sample Position'),
proteinname: sanitizeAndValidateColumn(row, fieldToCol['proteinname'], 'string', 'Protein Name'),
priority: sanitizeIntColumn(row, fieldToCol['priority'], 'Priority'),
comments: sanitizeAndValidateColumn(row, fieldToCol['comments'], 'string', 'Comments', true),
data_collection_parameters: collectDataParameters(row), // Consolidate data parameters
};
puck.samples.push(sample);
} else {
console.error(`Row ${rowIndex} is missing required fields for dewar/puck creation.`);
}
}
const dewarsArray = Array.from(dewars.values());
// Save for update dialog control
setDewarsToCreate(dewars);
if (dewarsArray.length > 0 && dewarsToReplace.length > 0) {
setDewarsToReplace(dewarsToReplace);
setShowUpdateDialog(true);
} else if (dewarsArray.length > 0) {
await handleDewarCreation(dewarsArray);
}
};
const sanitizeAndValidateColumn = (row, colIndex, type, columnName, isOptional = false) => {
const value = row?.data?.[colIndex];
const sanitizedValue = type === 'string' ? value?.trim() : value;
if (!sanitizedValue && !isOptional) {
console.error(`${columnName} is missing or invalid.`);
return null;
}
if (type === 'number' && isNaN(sanitizedValue)) {
console.error(`${columnName} is not a valid number.`);
}
return sanitizedValue;
};
// Utility to sanitize integer columns
const sanitizeIntColumn = (row, colIndex, columnName) => {
const rawValue = row?.data?.[colIndex];
const intValue = parseInt(rawValue, 10);
if (isNaN(intValue)) {
console.error(`${columnName} is not a valid integer.`);
}
return intValue;
};
// Consolidate data collection parameters
const collectDataParameters = (row) => ({
directory: sanitizeAndValidateColumn(row, fieldToCol['directory'], 'string', 'Directory', true),
oscillation: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['oscillation'], 'number', 'Oscillation', true)),
aperture: sanitizeAndValidateColumn(row, fieldToCol['aperture'], 'string', 'Aperture', true),
exposure: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['exposure'], 'number', 'Exposure', true)),
totalrange: sanitizeIntColumn(row, fieldToCol['totalrange'], 'Total Range'),
transmission: sanitizeIntColumn(row, fieldToCol['transmission'], 'Transmission'),
dose: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['dose'], 'number', 'Dose', true)),
targetresolution: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['targetresolution'], 'number', 'Target Resolution', true)),
datacollectiontype: sanitizeAndValidateColumn(row, fieldToCol['datacollectiontype'], 'string', 'Data Collection Type', true),
processingpipeline: sanitizeAndValidateColumn(row, fieldToCol['processingpipeline'], 'string', 'Processing Pipeline', true),
spacegroupnumber: sanitizeIntColumn(row, fieldToCol['spacegroupnumber'], 'Space Group Number'),
cellparameters: sanitizeAndValidateColumn(row, fieldToCol['cellparameters'], 'string', 'Cell Parameters', true),
rescutkey: sanitizeAndValidateColumn(row, fieldToCol['rescutkey'], 'string', 'Resolution Cut Key', true),
rescutvalue: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['rescutvalue'], 'number', 'Resolution Cut Value', true)),
userresolution: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['userresolution'], 'number', 'User Resolution', true)),
pdbid: sanitizeAndValidateColumn(row, fieldToCol['pdbid'], 'string', 'PDB ID', true),
autoprocfull: row.data[fieldToCol['autoprocfull']] === true,
procfull: row.data[fieldToCol['procfull']] === true,
adpenabled: row.data[fieldToCol['adpenabled']] === true,
noano: row.data[fieldToCol['noano']] === true,
ffcscampaign: row.data[fieldToCol['ffcscampaign']] === true,
trustedhigh: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['trustedhigh'], 'number', 'Trusted High', true)),
autoprocextraparams: sanitizeAndValidateColumn(row, fieldToCol['autoprocextraparams'], 'string', 'Autoproc Extra Params', true),
chiphiangles: parseFloat(sanitizeAndValidateColumn(row, fieldToCol['chiphiangles'], 'number', 'Chi/Phi Angles', true)),
});
const handleConfirmUpdate = async () => {
if (dewarsToReplace.length === 0) return;
try {
const dewarsArray = Array.from(dewarsToCreate.values());
// Perform the creation/update operation
await handleDewarCreation(dewarsArray);
console.log("Dewars replaced successfully");
} catch (error: any) {
console.error("Error replacing dewars:", error);
let errorMessage = error?.message || "Unexpected error occurred while replacing dewars.";
setErrorMessages((prevMessages) => [...prevMessages, errorMessage]);
setErrorDialogOpen(true);
}
// Reset controls after either success or failure
setShowUpdateDialog(false);
setDewarsToReplace([]);
setDewarsToCreate(new Map());
};
const handleCancelUpdate = () => {
setShowUpdateDialog(false);
setDewarsToReplace([]);
setDewarsToCreate(new Map());
};
const handleDewarCreation = async (dewarsArray: any[]) => {
const errorMessages: string[] = []; // Collect error messages for display
for (const dewar of dewarsArray) {
try {
const { number_of_pucks, number_of_samples, ...payload } = dewar;
// Attempt to create or update a dewar
await DewarsService.createOrUpdateDewarProtectedDewarsPost(selectedShipment.id, payload);
console.log(`Dewar "${dewar.dewar_name}" created/updated successfully.`);
} catch (error: any) {
// Log the full error object for debugging purposes
console.error("Full error object:", error);
let backendReason = "Unexpected error occurred."; // Default fallback message
if (error instanceof ApiError && error.body) {
// API error response (similar to delete route)
console.error("API error body:", error.body);
backendReason = error.body.detail || backendReason;
} else if (error?.response?.data?.detail) {
// Fallback for Axios-like errors
backendReason = error.response.data.detail;
} else if (error?.response) {
// Unexpected HTTP response (no error detail)
backendReason = `Unknown error occurred (Status: ${error.response.status}).`;
console.error("Error Response Data:", error.response.data);
} else if (error?.message) {
// Client-side error (e.g., network issues)
backendReason = error.message;
}
// Append the detailed error message to the list
errorMessages.push(`Dewar "${dewar.dewar_name}": ${backendReason}`);
}
}
// Notify the user if there are error messages
if (errorMessages.length > 0) {
setErrorMessages(errorMessages); // Set state to render an error dialog
setErrorDialogOpen(true); // Open the error dialog
// Re-throw the error to let the parent function (e.g., handleConfirmUpdate) know something went wrong
throw new Error("Error(s) occurred while creating/updating dewars.");
} else {
console.log("All dewars processed successfully.");
alert("All dewars created successfully!"); // Show success message
}
};
const handleSubmit = async () => {
if (isSubmitting) return;
if (!headers || headers.length === 0) {
console.error('Cannot submit, headers are not defined or empty');
return;
}
if (allCellsValid()) {
setIsSubmitting(true);
console.log('All data is valid. Proceeding with submission...');
await createOrUpdateDewarsFromSheet(
raw_data,
selectedShipment?.contact,
selectedShipment?.return_address
);
setIsSubmitting(false);
} else {
console.log('There are validation errors in the dataset. Please correct them before submission.');
}
};
const downloadCorrectedSpreadsheet = async () => {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(fileBlob);
const worksheet = workbook.getWorksheet(1);
raw_data.forEach((row, rowIndex) => {
row.data.forEach((value, colIndex) => {
worksheet.getRow(row.row_num).getCell(colIndex + 1).value = value;
});
});
workbook.xlsx.writeBuffer().then((buffer) => {
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
saveAs(blob, 'corrected_spreadsheet.xlsx');
});
};
return (
<TableContainer component={Paper}>
<Box display="flex" justifyContent="space-between" mb={2}>
<Typography variant="body2" style={{ backgroundColor: "#e6fbe6", padding: "4px 8px", borderRadius: "4px" }}>
Default Assigned (Light Green)
</Typography>
<Typography variant="body2" style={{ backgroundColor: "#fff8e1", padding: "4px 8px", borderRadius: "4px" }}>
Corrected (Light Yellow)
</Typography>
</Box>
<Table>
<TableHead>
<TableRow>
{headers.map((header, index) => (
<TableCell key={index} align="center">
<Typography variant="body2">{header}</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<Box display="flex" justifyContent="space-between" mt={2}>
<Button variant="contained" color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button variant="contained" color="primary" onClick={handleSubmit} disabled={!allCellsValid() || isSubmitting}>
Submit
</Button>
<Button variant="contained" onClick={downloadCorrectedSpreadsheet} disabled={!allCellsValid()}>
Download Corrected Spreadsheet
</Button>
</Box>
<TableBody>
{enhancedRawData.map((row, rowIndex) => (
<TableRow key={rowIndex}>
{headers.map((header, colIndex) => {
const key = `${row.row_num}-${header}`;
const errorMessage = errorMap.get(key);
const isInvalid = !!errorMessage;
const isDuplicateError = errorMessage?.toLowerCase().includes("duplicate position"); // Detect duplicate-specific messages
const cellValue = row.data[colIndex];
const editingValue = editingCell[`${rowIndex}-${colIndex}`];
const isCellCorrected = row.corrected_columns?.includes(header); // Use corrected metadata
const isDefaultAssigned = row.default_set_columns?.includes(header); // Dynamically match header name
return (
<TableCell
key={colIndex}
align="center"
style={{
backgroundColor: isDefaultAssigned
? "#e6fbe6" // Light green for default values
: isCellCorrected
? "#fff8e1" // Light yellow for corrections
: isDuplicateError
? "#fdecea" // Light red for duplicate errors
: "transparent", // No specific color for valid cells
color: isDefaultAssigned
? "#1b5e20" // Dark green for default values
: isDuplicateError
? "#d32f2f" // Bright red for duplicates
: "inherit",
fontWeight: isDefaultAssigned || isCellCorrected || isDuplicateError
? "bold"
: "normal",
cursor: isInvalid ? "pointer" : "default", // Allow editing invalid cells
}}
>
<Tooltip
title={
isDefaultAssigned
? "This value was automatically assigned as a default."
: isCellCorrected
? `Field "${header}" was auto-corrected.`
: isDuplicateError
? `Duplicate value detected in "${header}". Please ensure values are unique in this column.`
: errorMessage || ""
}
arrow
disableHoverListener={!isDefaultAssigned && !isCellCorrected && !isInvalid}
>
{isInvalid ? (
<TextField
value={editingValue !== undefined ? editingValue : cellValue}
onChange={(e) =>
setEditingCell({
...editingCell,
[`${rowIndex}-${colIndex}`]: e.target.value,
})
}
onBlur={() => handleCellBlur(rowIndex, colIndex)}
error={isInvalid}
fullWidth
variant="outlined"
size="small"
/>
) : (
cellValue
)}
</Tooltip>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
<Dialog
open={showUpdateDialog}
onClose={handleCancelUpdate}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">Replace Dewars</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
The following dewars already exist: {dewarsToReplace.map(dewar => dewar.dewar_name).join(', ')}. Would you like to replace them?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelUpdate} color="primary">
Cancel
</Button>
<Button onClick={handleConfirmUpdate} color="primary" autoFocus>
Replace
</Button>
</DialogActions>
</Dialog>
<Dialog
open={errorDialogOpen}
onClose={() => setErrorDialogOpen(false)}
aria-labelledby="error-dialog-title"
aria-describedby="error-dialog-description"
>
<DialogTitle>Error Creating/Updating Dewars</DialogTitle>
<DialogContent>
<DialogContentText>
The following errors occurred while processing dewars:
<ul>
{errorMessages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setErrorDialogOpen(false)} color="primary">
Close
</Button>
</DialogActions>
</Dialog>
</TableContainer>
);
};
export default SpreadsheetTable;