
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.
582 lines
25 KiB
TypeScript
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; |