From 44582cf38ef003c9f275805b2c933aeae2fb6b34 Mon Sep 17 00:00:00 2001 From: GotthardG <51994228+GotthardG@users.noreply.github.com> Date: Thu, 23 Jan 2025 13:57:25 +0100 Subject: [PATCH] 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. --- backend/app/data/data.py | 5 + backend/app/models.py | 7 +- backend/app/schemas.py | 1 + frontend/src/assets/icons/CrystalIcon.tsx | 18 ++ frontend/src/assets/icons/SimplePuckIcon.tsx | 46 +++++ frontend/src/components/DewarStepper.css | 14 ++ frontend/src/components/DewarStepper.tsx | 171 ++++++++++++------- frontend/src/components/ShipmentDetails.tsx | 149 +++++++++++----- 8 files changed, 310 insertions(+), 101 deletions(-) create mode 100644 frontend/src/assets/icons/CrystalIcon.tsx create mode 100644 frontend/src/assets/icons/SimplePuckIcon.tsx create mode 100644 frontend/src/components/DewarStepper.css diff --git a/backend/app/data/data.py b/backend/app/data/data.py index 7fe865e..d141b75 100644 --- a/backend/app/data/data.py +++ b/backend/app/data/data.py @@ -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, diff --git a/backend/app/models.py b/backend/app/models.py index e55ed59..4cc988d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4501e17..d07248d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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] diff --git a/frontend/src/assets/icons/CrystalIcon.tsx b/frontend/src/assets/icons/CrystalIcon.tsx new file mode 100644 index 0000000..63e9b13 --- /dev/null +++ b/frontend/src/assets/icons/CrystalIcon.tsx @@ -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; \ No newline at end of file diff --git a/frontend/src/assets/icons/SimplePuckIcon.tsx b/frontend/src/assets/icons/SimplePuckIcon.tsx new file mode 100644 index 0000000..7ecbecf --- /dev/null +++ b/frontend/src/assets/icons/SimplePuckIcon.tsx @@ -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 \ No newline at end of file diff --git a/frontend/src/components/DewarStepper.css b/frontend/src/components/DewarStepper.css new file mode 100644 index 0000000..e150acd --- /dev/null +++ b/frontend/src/components/DewarStepper.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/DewarStepper.tsx b/frontend/src/components/DewarStepper.tsx index e717257..9f5ad2e 100644 --- a/frontend/src/components/DewarStepper.tsx +++ b/frontend/src/components/DewarStepper.tsx @@ -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> diff --git a/frontend/src/components/ShipmentDetails.tsx b/frontend/src/components/ShipmentDetails.tsx index 77ccad1..0523184 100644 --- a/frontend/src/components/ShipmentDetails.tsx +++ b/frontend/src/components/ShipmentDetails.tsx @@ -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 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) => { -- GitLab