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 = [
Dewar(
id=1,
pgroups="p20001, p20002",
dewar_name="Dewar One",
dewar_type_id=1,
dewar_serial_number_id=2,
@ -204,6 +205,7 @@ dewars = [
),
Dewar(
id=2,
pgroups="p20001, p20002",
dewar_name="Dewar Two",
dewar_type_id=3,
dewar_serial_number_id=1,
@ -219,6 +221,7 @@ dewars = [
),
Dewar(
id=3,
pgroups="p20004",
dewar_name="Dewar Three",
dewar_type_id=2,
dewar_serial_number_id=3,
@ -234,6 +237,7 @@ dewars = [
),
Dewar(
id=4,
pgroups="p20004",
dewar_name="Dewar Four",
dewar_type_id=2,
dewar_serial_number_id=4,
@ -249,6 +253,7 @@ dewars = [
),
Dewar(
id=5,
pgroups="p20001, p20002",
dewar_name="Dewar Five",
dewar_type_id=1,
dewar_serial_number_id=1,

View File

@ -80,13 +80,14 @@ class Dewar(Base):
__tablename__ = "dewars"
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_serial_number_id = Column(
Integer, ForeignKey("dewar_serial_numbers.id"), nullable=True
)
tracking_number = Column(String(255))
status = Column(String(255))
tracking_number = Column(String(255), nullable=True)
status = Column(String(255), nullable=True)
ready_date = Column(Date, nullable=True)
shipping_date = Column(Date, nullable=True)
arrival_date = Column(Date, nullable=True)

View File

@ -523,6 +523,7 @@ class DewarCreate(DewarBase):
class Dewar(DewarBase):
id: int
pgroups: str
shipment_id: Optional[int]
contact: Optional[Contact]
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 { 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 StoreIcon from '@mui/icons-material/Store';
import RecycleIcon from '@mui/icons-material/Restore';
import AirplaneIcon from '@mui/icons-material/AirplanemodeActive';
import { Dewar, DewarsService } from "../../openapi";
import { DewarStatus, getStatusStepIndex, determineIconColor } from './statusUtils';
import Tooltip from "@mui/material/Tooltip";
import './DewarStepper.css';
const ICON_STYLE = { width: 24, height: 24 };
@ -16,15 +19,33 @@ const BottleIcon: React.FC<{ fill: string }> = ({ fill }) => (
);
// Icons Mapping
const ICONS: { [key: number]: React.ReactElement } = {
0: <BottleIcon fill="grey" />,
1: <AirplanemodeActiveIcon style={{ ...ICON_STYLE, color: 'blue' }} />,
2: <StoreIcon style={ICON_STYLE} />,
3: <RecycleIcon style={ICON_STYLE} />,
4: <AirplanemodeActiveIcon style={{ ...ICON_STYLE, color: 'green' }} />,
5: <AirplanemodeActiveIcon style={{ ...ICON_STYLE, color: 'orange' }} />,
const ICONS: {
[key: number]: (props?: React.ComponentProps<typeof AirplanemodeActiveIcon>) => React.ReactElement;
} = {
0: (props) => <BottleIcon fill="grey" {...props} />,
1: (props) => (
<AirplanemodeActiveIcon
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
interface StepIconContainerProps {
completed?: boolean;
@ -33,39 +54,42 @@ interface StepIconContainerProps {
children?: React.ReactNode;
}
const StepIconContainer: React.FC<StepIconContainerProps> = ({ completed, active, error, children }) => {
const StepIconContainer: React.FC<StepIconContainerProps> = ({
completed,
active,
error,
children,
}) => {
const className = [
completed ? 'completed' : '',
active ? 'active' : '',
error ? 'error' : '',
].join(' ').trim();
]
.filter(Boolean)
.join(' ');
return (
<div className={className}>
{children}
</div>
<div className={className}>{children}</div>
);
};
// StepIconComponent Props
type StepIconComponentProps = {
icon: number;
dewar: Dewar;
isSelected: boolean;
refreshShipments: () => void;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'icon'>;
// StepIconComponent
const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSelected, refreshShipments, ...rest }) => {
const StepIconComponent: React.FC<StepIconComponentProps> = ({
icon,
dewar,
isSelected,
refreshShipments,
...rest
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleIconEnter = (event: React.MouseEvent<HTMLDivElement>) => {
if (isSelected && icon === 0) {
const handleMenuOpen = (event: React.MouseEvent<HTMLDivElement>) => {
// Trigger menu ONLY for the BottleIcon (icon === 0)
if (icon === 0) {
setAnchorEl(event.currentTarget);
}
};
const handleIconLeave = () => {
const handleMenuClose = () => {
setAnchorEl(null);
};
@ -98,13 +122,12 @@ const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSe
};
const { iconIndex, color } = getIconProperties(icon, dewar);
const IconComponent = ICONS[iconIndex];
return (
<div
onMouseEnter={handleIconEnter}
onMouseLeave={handleIconLeave}
style={{ position: 'relative' }}
onMouseEnter={icon === 0 ? handleMenuOpen : undefined} // Open menu for BottleIcon
onMouseLeave={icon === 0 ? handleMenuClose : undefined} // Close menu when leaving BottleIcon
style={{ position: 'relative', cursor: 'pointer' }} // "Button-like" cursor for all icons
{...rest}
>
<StepIconContainer
@ -112,31 +135,36 @@ const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSe
active={Boolean(rest['aria-activedescendant'])}
error={rest.role === 'error'}
>
{IconComponent
? React.cloneElement(IconComponent, iconIndex === 0 ? { fill: color } : {})
: <Typography variant="body2" color="error">Invalid icon</Typography>
}
<Tooltip
title={icon === 1 ? `Tracking Number: ${dewar.tracking_number}` : ''} // Tooltip for Airplane icon
arrow
>
{ICONS[iconIndex]?.({
style: iconIndex === 0 ? { fill: color } : undefined,
}) ?? <Typography variant="body2" color="error">Invalid icon</Typography>}
</Tooltip>
</StepIconContainer>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleIconLeave}
MenuListProps={{
onMouseEnter: () => setAnchorEl(anchorEl),
onMouseLeave: handleIconLeave,
}}
>
{['In Preparation', 'Ready for Shipping'].map((status) => (
<MenuItem key={status} onClick={() => handleStatusChange(status as DewarStatus)}>
{status}
</MenuItem>
))}
</Menu>
{icon === 0 && (
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
MenuListProps={{
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)}>
{status}
</MenuItem>
))}
</Menu>
)}
</div>
);
};
// Icon properties retrieval based on the status and icon number
const getIconProperties = (icon: number, dewar: Dewar) => {
const status = dewar.status as DewarStatus;
@ -166,16 +194,41 @@ const CustomStepper: React.FC<CustomStepperProps> = ({ dewar, selectedDewarId, r
{steps.map((label, index) => (
<Step key={label}>
<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>
<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>
))}
</Stepper>

View File

@ -1,5 +1,5 @@
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 DeleteIcon from "@mui/icons-material/Delete";
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 CustomStepper from "./DewarStepper";
import DewarDetails from './DewarDetails';
import { PuckDetailsVisual } from '../assets/icons/SimplePuckIcon';
import CrystalFacetedIcon from "../assets/icons/CrystalIcon.tsx";
const MAX_COMMENTS_LENGTH = 200;
@ -183,6 +186,39 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({
const isCommentsEdited = comments !== initialComments;
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 (
<Box sx={{ ...sx, padding: 2, textAlign: 'left' }}>
{!localSelectedDewar && !isAddingDewar && (
@ -229,6 +265,9 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({
<Grid item xs={12} md={6}>
<Box sx={{ marginTop: 2, marginBottom: 2 }}>
<Typography variant="h5">{selectedShipment.shipment_name}</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
{renderPgroupChips()}
</Box>
<Typography variant="body1" color="textSecondary">
Main contact person: {contact ? `${contact.firstname} ${contact.lastname}` : 'N/A'}
</Typography>
@ -293,46 +332,78 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({
border: localSelectedDewar?.id === dewar.id ? '2px solid #000' : undefined,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginRight: 2 }}>
{dewar.unique_id ? (
<QRCode value={dewar.unique_id} size={70} />
) : (
<Box
sx={{
width: 70,
height: 70,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #ccc',
borderRadius: 1,
color: 'text.secondary'
}}
>
<Typography variant="body2">No QR Code</Typography>
<Box
sx={{
display: 'flex', // Flex container to align all items horizontally
alignItems: 'center', // Vertically align items in the center
justifyContent: 'space-between', // Distribute children evenly across the row
width: '100%', // Ensure the container spans full width
gap: 2, // Add consistent spacing between sections
}}
>
{/* Left: QR Code */}
<Box>
{dewar.unique_id ? (
<QRCode value={dewar.unique_id} size={60} />
) : (
<Box
sx={{
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 sx={{ flexGrow: 1 }}>
<Typography variant="body1">{dewar.dewar_name}</Typography>
<Typography variant="body2">Number of Pucks: {dewar.number_of_pucks || 0}</Typography>
<Typography variant="body2">Number of Samples: {dewar.number_of_samples || 0}</Typography>
<Typography variant="body2">Tracking Number: {dewar.tracking_number}</Typography>
<Typography variant="body2">
Contact Person: {dewar.contact?.firstname ? `${dewar.contact.firstname} ${dewar.contact.lastname}` : 'N/A'}
</Typography>
</Box>
<Box sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between'
}}>
<CustomStepper dewar={dewar} selectedDewarId={localSelectedDewar?.id ?? null} refreshShipments={refreshShipments} />
</Box>
{/* Middle-Right: Contact and Return Information */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" color="text.secondary">
Contact Person: {dewar.contact?.firstname ? `${dewar.contact.firstname} ${dewar.contact.lastname}` : 'N/A'}
</Typography>
<Typography variant="body2" color="text.secondary">
Return Address: {dewar.return_address?.house_number
? `${dewar.return_address.street}, ${dewar.return_address.city}`
: 'N/A'}
</Typography>
</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 && (
<IconButton
onClick={(e) => {