aaredb/frontend/src/components/SpreadsheetTable.tsx
GotthardG f6c19cc4da Refactor spreadsheet processing to improve validation logic
Enhanced value cleaning and validation for spreadsheet data with dynamic handling of columns and corrections. Improved feedback for users with detailed error messages and visual indicators for corrected or defaulted values. Simplified backend and frontend logic for better maintainability and usability.
2025-01-13 21:55:15 +01:00

582 lines
25 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
}) => {
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 initialNewDewarState = {
number_of_pucks: 0,
number_of_samples: 0,
ready_date: null,
shipping_date: null,
arrival_date: null,
returning_date: null,
qrcode: 'N/A',
contact_person_id: selectedShipment?.contact_person?.id,
return_address_id: selectedShipment?.return_address?.id,
dewar_name: '',
tracking_number: 'UNKNOWN',
status: 'In preparation',
pucks: [] // Ensure 'pucks' array exists
};
const [newDewar, setNewDewar] = useState(initialNewDewarState);
useEffect(() => {
setNewDewar((prev) => ({
...prev,
contact_person_id: selectedShipment?.contact_person?.id,
return_address_id: selectedShipment?.return_address?.id
}));
}, [selectedShipment]);
const generateErrorMap = (errorsList) => {
const errorMap = new Map();
if (Array.isArray(errorsList)) {
errorsList.forEach((error) => {
const key = `${error.row}-${headers[error.cell]}`;
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) {
// If valid, update the value (and use corrected_value if returned)
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)
);
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;
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) => {
if (!selectedShipment) return null;
try {
const shipDewars = await ShipmentsService.getDewarsByShipmentIdShipmentsShipmentIdDewarsGet(selectedShipment.id);
return shipDewars.find((d) => d.dewar_name === dewarName);
} catch (error) {
console.error('Failed to fetch existing dewars:', error);
return null;
}
};
const createOrUpdateDewarsFromSheet = async (data, contactPerson, returnAddress) => {
if (!contactPerson?.id || !returnAddress?.id) {
console.error('contact_person_id or return_address_id is missing');
return null;
}
const dewars = new Map();
const puckPositionMap = new Map();
const dewarsToReplace = [];
const dewarNameIdx = fieldToCol['dewarname'];
const puckNameIdx = fieldToCol['puckname'];
const puckTypeIdx = fieldToCol['pucktype'];
const sampleNameIdx = fieldToCol['crystalname'];
const proteinNameIdx = fieldToCol['proteinname'];
const samplePositionIdx = fieldToCol['positioninpuck'];
const priorityIdx = fieldToCol['priority'];
const commentsIdx = fieldToCol['comments'];
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
if (!row.data) {
console.error('Row data is missing');
continue;
}
// Extract values from the appropriate columns
const dewarName = typeof row.data[dewarNameIdx] === 'string' ? row.data[dewarNameIdx].trim() : null;
const puckName = row.data[puckNameIdx] !== undefined && row.data[puckNameIdx] !== null ? String(row.data[puckNameIdx]).trim() : null;
const puckType = typeof row.data[puckTypeIdx] === 'string' ? row.data[puckTypeIdx] : 'Unipuck';
const sampleName = typeof row.data[sampleNameIdx] === 'string' ? row.data[sampleNameIdx].trim() : null;
const proteinName = typeof row.data[proteinNameIdx] === 'string' ? row.data[proteinNameIdx].trim() : null;
const samplePosition = row.data[samplePositionIdx] !== undefined && row.data[samplePositionIdx] !== null ? Number(row.data[samplePositionIdx]) : null;
const priority = row?.data?.[priorityIdx] ? Number(row.data[priorityIdx]) : null;
const comments = typeof row.data[commentsIdx] === 'string' ? row.data[commentsIdx].trim() : null;
// Create data_collection_parameters object
const dataCollectionParameters = {
directory: row.data[fieldToCol['directory']],
oscillation: row.data[fieldToCol['oscillation']] ? parseFloat(row.data[fieldToCol['oscillation']]) : undefined,
aperture: row.data[fieldToCol['aperture']] ? row.data[fieldToCol['aperture']].trim() : undefined,
exposure: row.data[fieldToCol['exposure']] ? parseFloat(row.data[fieldToCol['exposure']]) : undefined,
totalrange: row.data[fieldToCol['totalrange']] ? parseInt(row.data[fieldToCol['totalrange']], 10) : undefined,
transmission: row.data[fieldToCol['transmission']] ? parseInt(row.data[fieldToCol['transmission']], 10) : undefined,
dose: row.data[fieldToCol['dose']] ? parseFloat(row.data[fieldToCol['dose']]) : undefined,
targetresolution: row.data[fieldToCol['targetresolution']] ? parseFloat(row.data[fieldToCol['targetresolution']]) : undefined,
datacollectiontype: row.data[fieldToCol['datacollectiontype']],
processingpipeline: row.data[fieldToCol['processingpipeline']],
spacegroupnumber: row.data[fieldToCol['spacegroupnumber']] ? parseInt(row.data[fieldToCol['spacegroupnumber']], 10) : undefined,
cellparameters: row.data[fieldToCol['cellparameters']],
rescutkey: row.data[fieldToCol['rescutkey']],
rescutvalue: row.data[fieldToCol['rescutvalue']] ? parseFloat(row.data[fieldToCol['rescutvalue']]) : undefined,
userresolution: row.data[fieldToCol['userresolution']] ? parseFloat(row.data[fieldToCol['userresolution']]) : undefined,
pdbid: row.data[fieldToCol['pdbid']],
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: row.data[fieldToCol['trustedhigh']] ? parseFloat(row.data[fieldToCol['trustedhigh']]) : undefined,
autoprocextraparams: row.data[fieldToCol['autoprocextraparams']],
chiphiangles: row.data[fieldToCol['chiphiangles']] ? parseFloat(row.data[fieldToCol['chiphiangles']]) : undefined,
};
if (dewarName && puckName) {
let dewar;
if (!dewars.has(dewarName)) {
dewar = {
...initialNewDewarState,
dewar_name: dewarName,
contact_person_id: contactPerson.id,
return_address_id: returnAddress.id,
pucks: []
};
dewars.set(dewarName, dewar);
puckPositionMap.set(dewarName, new Map());
// Check if the dewar exists in the shipment
const existingDewar = await checkIfDewarExists(dewarName);
if (existingDewar) {
dewarsToReplace.push(existingDewar);
}
} else {
dewar = dewars.get(dewarName);
}
let puckPositions = puckPositionMap.get(dewarName);
if (!puckPositions.has(puckName)) {
puckPositions.set(puckName, puckPositions.size + 1);
}
const puckPosition = puckPositions.get(puckName);
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);
}
const sample = {
sample_name: sampleName,
proteinname: proteinName,
position: samplePosition,
priority: priority,
comments: comments,
data_collection_parameters: dataCollectionParameters, // Attach the parameters
results: null // Placeholder for results field
};
if (isNaN(sample.position)) {
console.error(`Invalid sample position for sample ${sample.sample_name} in puck ${puckName}`);
} else {
puck.samples.push(sample);
}
} else {
if (!dewarName) {
console.error(`Dewar name is missing in row ${rowIndex}`);
}
if (!puckName) {
console.error(`Puck name is missing in row ${rowIndex}`);
}
}
}
const dewarsArray = Array.from(dewars.values());
// Save dewars array for later use in handleConfirmUpdate
setDewarsToCreate(dewars);
if (dewarsArray.length > 0 && dewarsToReplace.length > 0) {
setDewarsToReplace(dewarsToReplace);
setShowUpdateDialog(true);
} else {
await handleDewarCreation(dewarsArray);
}
};
const handleConfirmUpdate = async () => {
if (dewarsToReplace.length === 0) return;
try {
for (const dewar of dewarsToReplace) {
await DewarsService.deleteDewarDewarsDewarIdDelete(dewar.id);
}
const dewarsArray = Array.from(dewarsToCreate.values());
await handleDewarCreation(dewarsArray);
console.log('Dewars replaced successfully');
} catch (error) {
console.error('Error replacing dewar', error);
if (error instanceof ApiError && error.body) {
console.error('Validation errors:', error.body.detail);
} else {
console.error('Unexpected error:', error);
}
}
setShowUpdateDialog(false);
setDewarsToReplace([]);
setDewarsToCreate(new Map());
};
const handleCancelUpdate = () => {
setShowUpdateDialog(false);
setDewarsToReplace([]);
setDewarsToCreate(new Map());
};
const handleDewarCreation = async (dewarsArray) => {
for (const dewar of dewarsArray) {
try {
if (!dewar.pucks || dewar.pucks.length === 0) {
console.error(`Dewar ${dewar.dewar_name} does not have any pucks.`);
continue;
}
const createdDewar = await DewarsService.createDewarDewarsPost(dewar);
if (createdDewar && selectedShipment) {
await ShipmentsService.addDewarToShipmentShipmentsShipmentIdAddDewarPost(
selectedShipment.id,
createdDewar.id
);
console.log(`Dewar ${createdDewar.dewar_name} with ID ${createdDewar.id} created and added to the shipment.`);
}
} catch (error) {
console.error('Error adding dewar', error);
if (error instanceof ApiError && error.body) {
console.error('Validation errors:', error.body.detail);
} else {
console.error('Unexpected error:', error);
}
}
}
};
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_person,
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}>
<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>
{raw_data.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 cellValue = row.data[colIndex];
const editingValue = editingCell[`${rowIndex}-${colIndex}`];
const isCellCorrected = row.corrected_columns?.includes(header); // Check if this column is marked as corrected
const isDefaultAssigned = colIndex === 7 && row.default_set; // Default-assigned field exists and is true
// Dynamic styles for corrected cells
const cellStyle = {
backgroundColor:
isDefaultAssigned
? "#e6fbe6" // Light green for default values
: isCellCorrected
? "#fff8e1" // Light yellow for corrected values
: "transparent", // Default for others
color: isDefaultAssigned
? "#1b5e20" // Dark green for default values
: "inherit", // Default for others
fontWeight: (isCellCorrected || isDefaultAssigned) ? "bold" : "normal", // Bold text for any change
cursor: isInvalid ? "pointer" : "default", // Mouse pointer indicates interactive error cells
};
return (
<TableCell
key={colIndex}
align="center"
style={cellStyle}
>
<Tooltip
title={
isDefaultAssigned
? "This value was automatically assigned by the system as a default."
: isCellCorrected
? "Value corrected automatically by the system."
: 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>
</TableContainer>
);
};
export default SpreadsheetTable;