Add pgroup handling in dewars and enhance ShipmentDetails UI

Introduced a new `pgroups` attribute for dewars in the backend with schema and model updates. Modified the frontend to display `pgroups` as chips, integrate new visual icons for pucks and crystals, and enhance the UI/UX in `ShipmentDetails` and `DewarStepper` components. Added reusable SVG components for better modularity and design consistency.
This commit is contained in:
GotthardG 2025-01-23 13:57:25 +01:00
parent 173e192fc4
commit 44582cf38e
8 changed files with 309 additions and 100 deletions

View File

@ -189,6 +189,7 @@ def generate_unique_id(length=16):
dewars = [ dewars = [
Dewar( Dewar(
id=1, id=1,
pgroups="p20001, p20002",
dewar_name="Dewar One", dewar_name="Dewar One",
dewar_type_id=1, dewar_type_id=1,
dewar_serial_number_id=2, dewar_serial_number_id=2,
@ -204,6 +205,7 @@ dewars = [
), ),
Dewar( Dewar(
id=2, id=2,
pgroups="p20001, p20002",
dewar_name="Dewar Two", dewar_name="Dewar Two",
dewar_type_id=3, dewar_type_id=3,
dewar_serial_number_id=1, dewar_serial_number_id=1,
@ -219,6 +221,7 @@ dewars = [
), ),
Dewar( Dewar(
id=3, id=3,
pgroups="p20004",
dewar_name="Dewar Three", dewar_name="Dewar Three",
dewar_type_id=2, dewar_type_id=2,
dewar_serial_number_id=3, dewar_serial_number_id=3,
@ -234,6 +237,7 @@ dewars = [
), ),
Dewar( Dewar(
id=4, id=4,
pgroups="p20004",
dewar_name="Dewar Four", dewar_name="Dewar Four",
dewar_type_id=2, dewar_type_id=2,
dewar_serial_number_id=4, dewar_serial_number_id=4,
@ -249,6 +253,7 @@ dewars = [
), ),
Dewar( Dewar(
id=5, id=5,
pgroups="p20001, p20002",
dewar_name="Dewar Five", dewar_name="Dewar Five",
dewar_type_id=1, dewar_type_id=1,
dewar_serial_number_id=1, dewar_serial_number_id=1,

View File

@ -80,13 +80,14 @@ class Dewar(Base):
__tablename__ = "dewars" __tablename__ = "dewars"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
dewar_name = Column(String(255)) pgroups = Column(String(255), nullable=False)
dewar_name = Column(String(255), nullable=False)
dewar_type_id = Column(Integer, ForeignKey("dewar_types.id"), nullable=True) dewar_type_id = Column(Integer, ForeignKey("dewar_types.id"), nullable=True)
dewar_serial_number_id = Column( dewar_serial_number_id = Column(
Integer, ForeignKey("dewar_serial_numbers.id"), nullable=True Integer, ForeignKey("dewar_serial_numbers.id"), nullable=True
) )
tracking_number = Column(String(255)) tracking_number = Column(String(255), nullable=True)
status = Column(String(255)) status = Column(String(255), nullable=True)
ready_date = Column(Date, nullable=True) ready_date = Column(Date, nullable=True)
shipping_date = Column(Date, nullable=True) shipping_date = Column(Date, nullable=True)
arrival_date = Column(Date, nullable=True) arrival_date = Column(Date, nullable=True)

View File

@ -523,6 +523,7 @@ class DewarCreate(DewarBase):
class Dewar(DewarBase): class Dewar(DewarBase):
id: int id: int
pgroups: str
shipment_id: Optional[int] shipment_id: Optional[int]
contact: Optional[Contact] contact: Optional[Contact]
return_address: Optional[Address] return_address: Optional[Address]

View File

@ -0,0 +1,18 @@
import React from "react";
const CrystalFacetedIcon: React.FC<{ size?: number; color?: string }> = ({ size = 50, color = "#28A745" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={color}
>
{/* Facets */}
<polygon points="12,2 19,9.5 12,22 5,9.5" fill={color} />
<polygon points="12,2 5,9.5 19,9.5" fill="#e3f2fd" />
<polygon points="12,22 19,9.5 5,9.5" fill="rgba(0,0,0,0.1)" />
</svg>
);
export default CrystalFacetedIcon;

View File

@ -0,0 +1,46 @@
import React from "react";
import { Box, Typography } from "@mui/material";
// Define the props interface for PuckDetailsVisual
interface PuckDetailsVisualProps {
puckCount: number; // Total number of pucks
}
// This component purely represents a simple puck icon with 16 filled black circles
const SimplePuckIcon: React.FC = () => (
<svg width="50" height="50" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" stroke="black" strokeWidth="2" fill="none" />
{[...Array(11)].map((_, index) => {
const angle = (index * (360 / 11)) * (Math.PI / 180);
const x = 50 + 35 * Math.cos(angle);
const y = 50 + 35 * Math.sin(angle);
return <circle key={index} cx={x} cy={y} r="5" fill="black" />;
})}
{[...Array(5)].map((_, index) => {
const angle = (index * (360 / 5) + 36) * (Math.PI / 180);
const x = 50 + 15 * Math.cos(angle);
const y = 50 + 15 * Math.sin(angle);
return <circle key={index + 11} cx={x} cy={y} r="5" fill="black" />;
})}
</svg>
);
// A wrapper component for displaying the puck icon and the count
export const PuckDetailsVisual: React.FC<PuckDetailsVisualProps> = ({ puckCount }) => {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<SimplePuckIcon />
<Typography variant="body1" fontWeight="bold">
{puckCount}
</Typography>
</Box>
);
};
export default SimplePuckIcon; // Only SimplePuckIcon will be the default export

View File

@ -0,0 +1,14 @@
.completed {
background-color: #e0ffe0;
cursor: pointer; /* Ensure pointer is enabled */
}
.active {
background-color: #f0f8ff;
cursor: pointer;
}
.error {
background-color: #ffe0e0;
cursor: pointer;
}

View File

@ -1,10 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Stepper, Step, StepLabel, Typography, Menu, MenuItem } from '@mui/material'; import {Stepper, Step, StepLabel, Typography, Menu, MenuItem, IconButton, Box} from '@mui/material';
import AirplanemodeActiveIcon from '@mui/icons-material/AirplanemodeActive'; import AirplanemodeActiveIcon from '@mui/icons-material/AirplanemodeActive';
import StoreIcon from '@mui/icons-material/Store'; import StoreIcon from '@mui/icons-material/Store';
import RecycleIcon from '@mui/icons-material/Restore'; import RecycleIcon from '@mui/icons-material/Restore';
import AirplaneIcon from '@mui/icons-material/AirplanemodeActive';
import { Dewar, DewarsService } from "../../openapi"; import { Dewar, DewarsService } from "../../openapi";
import { DewarStatus, getStatusStepIndex, determineIconColor } from './statusUtils'; import { DewarStatus, getStatusStepIndex, determineIconColor } from './statusUtils';
import Tooltip from "@mui/material/Tooltip";
import './DewarStepper.css';
const ICON_STYLE = { width: 24, height: 24 }; const ICON_STYLE = { width: 24, height: 24 };
@ -16,15 +19,33 @@ const BottleIcon: React.FC<{ fill: string }> = ({ fill }) => (
); );
// Icons Mapping // Icons Mapping
const ICONS: { [key: number]: React.ReactElement } = { const ICONS: {
0: <BottleIcon fill="grey" />, [key: number]: (props?: React.ComponentProps<typeof AirplanemodeActiveIcon>) => React.ReactElement;
1: <AirplanemodeActiveIcon style={{ ...ICON_STYLE, color: 'blue' }} />, } = {
2: <StoreIcon style={ICON_STYLE} />, 0: (props) => <BottleIcon fill="grey" {...props} />,
3: <RecycleIcon style={ICON_STYLE} />, 1: (props) => (
4: <AirplanemodeActiveIcon style={{ ...ICON_STYLE, color: 'green' }} />, <AirplanemodeActiveIcon
5: <AirplanemodeActiveIcon style={{ ...ICON_STYLE, color: 'orange' }} />, style={{ ...ICON_STYLE, color: 'blue', cursor: 'pointer' }}
{...props} // Explicitly typing props
/>
),
2: (props) => <StoreIcon style={ICON_STYLE} {...props} />,
3: (props) => <RecycleIcon style={ICON_STYLE} {...props} />,
4: (props) => (
<AirplanemodeActiveIcon
style={{ ...ICON_STYLE, cursor: 'pointer' }}
color="success" // Use one of the predefined color keywords
{...props}
/>
),
5: (props) => (
<AirplanemodeActiveIcon
style={{ ...ICON_STYLE, cursor: 'pointer' }}
color="warning" // Use predefined keyword for color
{...props}
/>
),
}; };
// StepIconContainer Component // StepIconContainer Component
interface StepIconContainerProps { interface StepIconContainerProps {
completed?: boolean; completed?: boolean;
@ -33,39 +54,42 @@ interface StepIconContainerProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
const StepIconContainer: React.FC<StepIconContainerProps> = ({ completed, active, error, children }) => { const StepIconContainer: React.FC<StepIconContainerProps> = ({
completed,
active,
error,
children,
}) => {
const className = [ const className = [
completed ? 'completed' : '', completed ? 'completed' : '',
active ? 'active' : '', active ? 'active' : '',
error ? 'error' : '', error ? 'error' : '',
].join(' ').trim(); ]
.filter(Boolean)
.join(' ');
return ( return (
<div className={className}> <div className={className}>{children}</div>
{children}
</div>
); );
}; };
// StepIconComponent Props const StepIconComponent: React.FC<StepIconComponentProps> = ({
type StepIconComponentProps = { icon,
icon: number; dewar,
dewar: Dewar; isSelected,
isSelected: boolean; refreshShipments,
refreshShipments: () => void; ...rest
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'icon'>; }) => {
// StepIconComponent
const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSelected, refreshShipments, ...rest }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleIconEnter = (event: React.MouseEvent<HTMLDivElement>) => { const handleMenuOpen = (event: React.MouseEvent<HTMLDivElement>) => {
if (isSelected && icon === 0) { // Trigger menu ONLY for the BottleIcon (icon === 0)
if (icon === 0) {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
} }
}; };
const handleIconLeave = () => { const handleMenuClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
@ -98,13 +122,12 @@ const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSe
}; };
const { iconIndex, color } = getIconProperties(icon, dewar); const { iconIndex, color } = getIconProperties(icon, dewar);
const IconComponent = ICONS[iconIndex];
return ( return (
<div <div
onMouseEnter={handleIconEnter} onMouseEnter={icon === 0 ? handleMenuOpen : undefined} // Open menu for BottleIcon
onMouseLeave={handleIconLeave} onMouseLeave={icon === 0 ? handleMenuClose : undefined} // Close menu when leaving BottleIcon
style={{ position: 'relative' }} style={{ position: 'relative', cursor: 'pointer' }} // "Button-like" cursor for all icons
{...rest} {...rest}
> >
<StepIconContainer <StepIconContainer
@ -112,31 +135,36 @@ const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSe
active={Boolean(rest['aria-activedescendant'])} active={Boolean(rest['aria-activedescendant'])}
error={rest.role === 'error'} error={rest.role === 'error'}
> >
{IconComponent <Tooltip
? React.cloneElement(IconComponent, iconIndex === 0 ? { fill: color } : {}) title={icon === 1 ? `Tracking Number: ${dewar.tracking_number}` : ''} // Tooltip for Airplane icon
: <Typography variant="body2" color="error">Invalid icon</Typography> arrow
} >
{ICONS[iconIndex]?.({
style: iconIndex === 0 ? { fill: color } : undefined,
}) ?? <Typography variant="body2" color="error">Invalid icon</Typography>}
</Tooltip>
</StepIconContainer> </StepIconContainer>
<Menu {icon === 0 && (
anchorEl={anchorEl} <Menu
open={Boolean(anchorEl)} anchorEl={anchorEl}
onClose={handleIconLeave} open={Boolean(anchorEl)}
MenuListProps={{ onClose={handleMenuClose}
onMouseEnter: () => setAnchorEl(anchorEl), MenuListProps={{
onMouseLeave: handleIconLeave, onMouseEnter: () => setAnchorEl(anchorEl), // Keep menu open on hover
}} onMouseLeave: handleMenuClose, // Close menu when leaving
> }}
{['In Preparation', 'Ready for Shipping'].map((status) => ( >
<MenuItem key={status} onClick={() => handleStatusChange(status as DewarStatus)}> {['In Preparation', 'Ready for Shipping'].map((status) => (
{status} <MenuItem key={status} onClick={() => handleStatusChange(status as DewarStatus)}>
</MenuItem> {status}
))} </MenuItem>
</Menu> ))}
</Menu>
)}
</div> </div>
); );
}; };
// Icon properties retrieval based on the status and icon number // Icon properties retrieval based on the status and icon number
const getIconProperties = (icon: number, dewar: Dewar) => { const getIconProperties = (icon: number, dewar: Dewar) => {
const status = dewar.status as DewarStatus; const status = dewar.status as DewarStatus;
@ -166,16 +194,41 @@ const CustomStepper: React.FC<CustomStepperProps> = ({ dewar, selectedDewarId, r
{steps.map((label, index) => ( {steps.map((label, index) => (
<Step key={label}> <Step key={label}>
<StepLabel <StepLabel
StepIconComponent={(stepProps) => <StepIconComponent {...stepProps} icon={index} dewar={dewar} isSelected={isSelected} refreshShipments={refreshShipments} />} StepIconComponent={(stepProps) => (
<StepIconComponent
{...stepProps}
icon={index}
dewar={dewar}
isSelected={isSelected}
refreshShipments={refreshShipments}
/>
)}
> >
{label} <Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1, // Space between text and icon if applied
}}
>
{/* Step label */}
<Typography variant="body2">
{label}
</Typography>
</Box>
{/* Optional: Date below the step */}
<Typography variant="body2">
{index === 0
? dewar.ready_date
: index === 1
? dewar.shipping_date
: index === 2
? dewar.arrival_date
: index === 3
? dewar.returning_date
: ''}
</Typography>
</StepLabel> </StepLabel>
<Typography variant="body2">
{index === 0 ? dewar.ready_date :
index === 1 ? dewar.shipping_date :
index === 2 ? dewar.arrival_date :
index === 3 ? dewar.returning_date : ''}
</Typography>
</Step> </Step>
))} ))}
</Stepper> </Stepper>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, Typography, Button, Stack, TextField, IconButton, Grid } from '@mui/material'; import {Box, Typography, Button, Stack, TextField, IconButton, Grid, Chip} from '@mui/material';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from '@mui/icons-material/Check';
@ -8,6 +8,9 @@ import { Dewar, DewarsService, Shipment, Contact, ApiError, ShipmentsService } f
import { SxProps } from "@mui/system"; import { SxProps } from "@mui/system";
import CustomStepper from "./DewarStepper"; import CustomStepper from "./DewarStepper";
import DewarDetails from './DewarDetails'; import DewarDetails from './DewarDetails';
import { PuckDetailsVisual } from '../assets/icons/SimplePuckIcon';
import CrystalFacetedIcon from "../assets/icons/CrystalIcon.tsx";
const MAX_COMMENTS_LENGTH = 200; const MAX_COMMENTS_LENGTH = 200;
@ -183,6 +186,39 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({
const isCommentsEdited = comments !== initialComments; const isCommentsEdited = comments !== initialComments;
const contact = selectedShipment?.contact; const contact = selectedShipment?.contact;
const renderPgroupChips = () => {
// Safely handle pgroups as an array
const pgroupsArray = Array.isArray(selectedShipment?.pgroups)
? selectedShipment.pgroups
: selectedShipment?.pgroups?.split(",").map((pgroup: string) => pgroup.trim()) || [];
if (!pgroupsArray.length) {
return <Typography variant="body2">No associated pgroups</Typography>;
}
return pgroupsArray.map((pgroup: string) => (
<Chip
key={pgroup}
label={pgroup}
color={pgroup === activePgroup ? "primary" : "default"} // Highlight active pgroups
sx={{
margin: 0.5,
backgroundColor: pgroup === activePgroup ? '#19d238' : '#b0b0b0',
color: pgroup === activePgroup ? 'white' : 'black',
fontWeight: 'bold',
borderRadius: '8px',
height: '20px',
fontSize: '12px',
boxShadow: '0px 1px 3px rgba(0, 0, 0, 0.2)',
//cursor: isAssociated ? 'default' : 'pointer', // Disable pointer for associated chips
//'&:hover': { opacity: isAssociated ? 1 : 0.8 }, // Disable hover effect for associated chips
mr: 1,
mb: 1,
}}
/>
));
};
return ( return (
<Box sx={{ ...sx, padding: 2, textAlign: 'left' }}> <Box sx={{ ...sx, padding: 2, textAlign: 'left' }}>
{!localSelectedDewar && !isAddingDewar && ( {!localSelectedDewar && !isAddingDewar && (
@ -229,6 +265,9 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
<Box sx={{ marginTop: 2, marginBottom: 2 }}> <Box sx={{ marginTop: 2, marginBottom: 2 }}>
<Typography variant="h5">{selectedShipment.shipment_name}</Typography> <Typography variant="h5">{selectedShipment.shipment_name}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
{renderPgroupChips()}
</Box>
<Typography variant="body1" color="textSecondary"> <Typography variant="body1" color="textSecondary">
Main contact person: {contact ? `${contact.firstname} ${contact.lastname}` : 'N/A'} Main contact person: {contact ? `${contact.firstname} ${contact.lastname}` : 'N/A'}
</Typography> </Typography>
@ -293,46 +332,78 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({
border: localSelectedDewar?.id === dewar.id ? '2px solid #000' : undefined, border: localSelectedDewar?.id === dewar.id ? '2px solid #000' : undefined,
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 2 }}> <Box
{dewar.unique_id ? ( sx={{
<QRCode value={dewar.unique_id} size={70} /> display: 'flex', // Flex container to align all items horizontally
) : ( alignItems: 'center', // Vertically align items in the center
<Box justifyContent: 'space-between', // Distribute children evenly across the row
sx={{ width: '100%', // Ensure the container spans full width
width: 70, gap: 2, // Add consistent spacing between sections
height: 70, }}
display: 'flex', >
alignItems: 'center', {/* Left: QR Code */}
justifyContent: 'center', <Box>
border: '1px dashed #ccc', {dewar.unique_id ? (
borderRadius: 1, <QRCode value={dewar.unique_id} size={60} />
color: 'text.secondary' ) : (
}} <Box
> sx={{
<Typography variant="body2">No QR Code</Typography> width: 60,
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #ccc',
borderRadius: 1,
color: 'text.secondary',
}}
>
<Typography variant="body2">No QR Code</Typography>
</Box>
)}
</Box>
{/* Middle-Left: Dewar Information */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="h6" fontWeight="bold">
{dewar.dewar_name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PuckDetailsVisual puckCount={dewar.number_of_pucks || 0} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CrystalFacetedIcon size={20} />
<Typography variant="body2">{dewar.number_of_samples || 0} Samples</Typography>
</Box>
</Box> </Box>
)} </Box>
</Box>
<Box sx={{ flexGrow: 1 }}> {/* Middle-Right: Contact and Return Information */}
<Typography variant="body1">{dewar.dewar_name}</Typography> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2">Number of Pucks: {dewar.number_of_pucks || 0}</Typography> <Typography variant="body2" color="text.secondary">
<Typography variant="body2">Number of Samples: {dewar.number_of_samples || 0}</Typography> Contact Person: {dewar.contact?.firstname ? `${dewar.contact.firstname} ${dewar.contact.lastname}` : 'N/A'}
<Typography variant="body2">Tracking Number: {dewar.tracking_number}</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2" color="text.secondary">
Contact Person: {dewar.contact?.firstname ? `${dewar.contact.firstname} ${dewar.contact.lastname}` : 'N/A'} Return Address: {dewar.return_address?.house_number
</Typography> ? `${dewar.return_address.street}, ${dewar.return_address.city}`
</Box> : 'N/A'}
<Box sx={{ </Typography>
flexGrow: 1, </Box>
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between'
}}>
<CustomStepper dewar={dewar} selectedDewarId={localSelectedDewar?.id ?? null} refreshShipments={refreshShipments} />
</Box>
{/* Right: Stepper */}
<Box
sx={{
flexGrow: 1, // Allow the stepper to expand and use space effectively
maxWidth: '400px', // Optional: Limit how wide the stepper can grow
}}
>
<CustomStepper
dewar={dewar}
selectedDewarId={localSelectedDewar?.id ?? null}
refreshShipments={refreshShipments}
sx={{ width: '100%' }} // Make the stepper fill its container
/>
</Box>
</Box>
{localSelectedDewar?.id === dewar.id && ( {localSelectedDewar?.id === dewar.id && (
<IconButton <IconButton
onClick={(e) => { onClick={(e) => {