diff --git a/backend/app/models.py b/backend/app/models.py index 651708e..4f95bda 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -96,6 +96,7 @@ class Dewar(Base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) pgroups = Column(String(255), nullable=False) dewar_name = Column(String(255), nullable=False) + created_at = Column(DateTime, default=datetime.now, 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 diff --git a/backend/app/routers/dewar.py b/backend/app/routers/dewar.py index 722aa2e..5dd7b8c 100644 --- a/backend/app/routers/dewar.py +++ b/backend/app/routers/dewar.py @@ -1,6 +1,7 @@ import os import tempfile import time +from datetime import datetime, timedelta import random import hashlib from fastapi import APIRouter, HTTPException, status, Depends, Response @@ -21,7 +22,10 @@ from app.schemas import ( Sample, Puck, SampleEventResponse, - DewarSchema, # Clearer name for schema + DewarSchema, + loginData, + DewarWithPucksResponse, + PuckResponse, ) from app.models import ( Dewar as DewarModel, @@ -44,6 +48,7 @@ from reportlab.pdfgen import canvas from app.crud import ( get_shipment_by_id, ) +from app.routers.auth import get_current_user dewar_router = APIRouter() @@ -543,6 +548,64 @@ def get_all_serial_numbers(db: Session = Depends(get_db)): raise HTTPException(status_code=500, detail="Internal server error") +@dewar_router.get( + "/recent-dewars-with-pucks", + response_model=List[DewarWithPucksResponse], + operation_id="getRecentDewarsWithPucks", +) +async def get_recent_dewars_with_pucks( + db: Session = Depends(get_db), current_user: loginData = Depends(get_current_user) +): + # Get the timestamp for two months ago + two_months_ago = datetime.now() - timedelta(days=60) + + # Query dewars for this user created in the last 2 months + dewars = ( + db.query(DewarModel) + .options(joinedload(DewarModel.pucks)) # Eager load pucks + .filter( + DewarModel.pgroups.in_(current_user.pgroups), + DewarModel.created_at >= two_months_ago, + ) + .all() + ) + + result = [] + for dewar in dewars: + pucks = db.query(PuckModel).filter(PuckModel.dewar_id == dewar.id).all() + result.append( + DewarWithPucksResponse( + id=dewar.id, + dewar_name=dewar.dewar_name, + created_at=dewar.created_at, + pucks=[ + PuckResponse(id=puck.id, puck_name=puck.puck_name) for puck in pucks + ], + ) + ) + return result + + +@dewar_router.patch( + "/dewar/{dewar_id}/assign-beamtime", operation_id="assignDewarToBeamtime" +) +async def assign_beamtime_to_dewar( + dewar_id: int, + beamtime_id: int, # Use Query if you want this from ?beamtime_id=... + db: Session = Depends(get_db), +): + dewar = db.query(DewarModel).filter(DewarModel.id == dewar_id).first() + if not dewar: # <- Move check earlier! + raise HTTPException(status_code=404, detail="Dewar not found") + if beamtime_id == 0: + dewar.beamtime_id = None + else: + dewar.beamtime_id = beamtime_id + db.commit() + db.refresh(dewar) + return {"status": "success", "dewar_id": dewar.id, "beamtime_id": beamtime_id} + + @dewar_router.get("/{dewar_id}", response_model=Dewar) async def get_dewar(dewar_id: int, db: Session = Depends(get_db)): dewar = ( diff --git a/backend/app/routers/puck.py b/backend/app/routers/puck.py index cff0801..953bd00 100644 --- a/backend/app/routers/puck.py +++ b/backend/app/routers/puck.py @@ -658,3 +658,21 @@ async def get_pucks_by_slot(slot_identifier: str, db: Session = Depends(get_db)) ) return pucks + + +@router.patch("/puck/{puck_id}/assign-beamtime", operation_id="assignPuckToBeamtime") +async def assign_beamtime_to_puck( + puck_id: int, + beamtime_id: int, # If you want ?beamtime_id=123 in the query + db: Session = Depends(get_db), +): + puck = db.query(PuckModel).filter(PuckModel.id == puck_id).first() + if not puck: + raise HTTPException(status_code=404, detail="Puck not found") + if beamtime_id == 0: + puck.beamtime_id = None + else: + puck.beamtime_id = beamtime_id + db.commit() + db.refresh(puck) + return {"status": "success", "puck_id": puck.id, "beamtime_id": beamtime_id} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 14a8ffd..85cca2e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -568,6 +568,7 @@ class DewarBase(BaseModel): tracking_number: str number_of_pucks: Optional[int] = None number_of_samples: Optional[int] = None + created_at: Optional[datetime] = None status: str contact_id: Optional[int] return_address_id: Optional[int] @@ -584,6 +585,7 @@ class DewarCreate(DewarBase): class Dewar(DewarBase): id: int pgroups: str + created_at: Optional[datetime] = None shipment_id: Optional[int] contact: Optional[Contact] return_address: Optional[Address] @@ -772,6 +774,18 @@ class PuckWithTellPosition(BaseModel): from_attributes = True +class PuckResponse(BaseModel): + id: int + puck_name: str + + +class DewarWithPucksResponse(BaseModel): + id: int + dewar_name: str + created_at: datetime + pucks: List[PuckResponse] + + class Beamtime(BaseModel): id: int pgroups: str diff --git a/frontend/src/components/Calendar.tsx b/frontend/src/components/Calendar.tsx index f686045..3c06b7c 100644 --- a/frontend/src/components/Calendar.tsx +++ b/frontend/src/components/Calendar.tsx @@ -4,12 +4,13 @@ import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import '../styles/Calendar.css'; +import { BeamtimesService, DewarsService, PucksService } from '../../openapi'; // Define colors for each beamline const beamlineColors: { [key: string]: string } = { - PXI: '#FF5733', - PXII: '#33FF57', - PXIII: '#3357FF', + X06SA: '#FF5733', + X10SA: '#33FF57', + X06DA: '#3357FF', Unknown: '#CCCCCC', // Gray color for unknown beamlines }; @@ -38,8 +39,11 @@ const darkenColor = (color: string, percent: number): string => { const Calendar: React.FC = () => { const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [fetchError, setFetchError] = useState(null); const [selectedEventId, setSelectedEventId] = useState(null); const [eventDetails, setEventDetails] = useState(null); + const [eventAssociations, setEventAssociations] = useState<{ [eventId: string]: {dewars: string[], pucks: string[]} }>({}); const [userDetails, setUserDetails] = useState({ name: '', firstName: '', @@ -51,76 +55,94 @@ const Calendar: React.FC = () => { const [shipments, setShipments] = useState([]); // State for shipments const [selectedDewars, setSelectedDewars] = useState([]); // Track selected dewars for the experiment + const fetchBeamtimes = async () => { + setIsLoading(true); + setFetchError(null); + + try { + const beamtimeData = await BeamtimesService.getMyBeamtimesProtectedBeamtimesMyBeamtimesGet(); // Replace with actual API function + const formattedEvents: CustomEvent[] = beamtimeData.map((beamtime: any) => ({ + id: `${beamtime.beamline}-${beamtime.start_date}`, + title: `${beamtime.beamline}: ${beamtime.shift}`, + start: beamtime.start_date, + end: beamtime.end_date, + beamtime_id: beamtime.id, + beamline: beamtime.beamline || 'Unknown', + beamtime_shift: beamtime.shift || 'Unknown', + backgroundColor: beamlineColors[beamtime.beamline] || beamlineColors.Unknown, + borderColor: '#000', + textColor: '#fff', + })); + + setEvents(formattedEvents); + } catch (error) { + console.error('Failed to fetch beamtime data:', error); + setFetchError('Failed to load beamtime data. Please try again later.'); + } finally { + setIsLoading(false); + } + }; + + // Fetch beamtimes on component mount useEffect(() => { - const fetchEvents = async () => { - try { - const response = await fetch('/beamtimedb.json'); - const data = await response.json(); - const events: CustomEvent[] = []; - - data.beamtimes.forEach((beamtime: any) => { - const date = new Date(beamtime.date); - beamtime.shifts.forEach((shift: any) => { - const beamline = shift.beamline || 'Unknown'; - const beamtime_shift = shift.beamtime_shift || 'morning'; - - const event: CustomEvent = { - id: `${beamline}-${date.toISOString()}-${beamtime_shift}`, - start: new Date(date.setHours(0, 0, 0)), - end: new Date(date.setHours(23, 59, 59)), - title: `${beamline}: ${beamtime_shift}`, - beamline, - beamtime_shift, - isSubmitted: false, - }; - - events.push(event); - }); - }); - - console.log('Fetched events array:', events); - setEvents(events); - } catch (error) { - console.error('Error fetching events:', error); - } - }; - - const fetchShipments = async () => { - try { - const response = await fetch('/shipmentdb.json'); - - // Check for HTTP errors - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - // Parse the JSON response - const data = await response.json(); - - const availableDewars: any[] = []; - - data.shipments.forEach(shipment => { - if (shipment.shipment_status === "In Transit") { - shipment.dewars.forEach(dewar => { - if (dewar.shippingStatus === "shipped" && dewar.returned === "") { - availableDewars.push(dewar); - } - }); - } - }); - - console.log('Available Dewars:', availableDewars); - setShipments(availableDewars); - } catch (error) { - console.error('Error fetching shipments:', error); - // Optionally display the error to the user in the UI - } - }; - - fetchEvents(); - fetchShipments(); + fetchBeamtimes(); }, []); + useEffect(() => { + // Only fetch if you want to update dewars when eventDetails change, + // or run only on initial mount by passing [] as deps + if (eventDetails) { + const fetchDewars = async () => { + try { + // This name depends on your codegen's operationId / function name! + const dewars = await DewarsService.getRecentDewarsWithPucks(); + setShipments(dewars); + } catch (err) { + // Optionally handle error + console.error("Failed to fetch dewars with pucks", err); + } + }; + fetchDewars(); + } + }, [eventDetails]); // Fetch dewars when an event is selected + + + //const fetchShipments = async () => { + // try { + // const response = await fetch('/shipmentdb.json'); + // + // // Check for HTTP errors + // if (!response.ok) { + // throw new Error(`HTTP error! status: ${response.status}`); + // } + // + // // Parse the JSON response + // const data = await response.json(); + // + // const availableDewars: any[] = []; + // + // data.shipments.forEach(shipment => { + // if (shipment.shipment_status === "In Transit") { + // shipment.dewars.forEach(dewar => { + // if (dewar.shippingStatus === "shipped" && dewar.returned === "") { + // availableDewars.push(dewar); + // } + // }); + // } + // }); + // + // console.log('Available Dewars:', availableDewars); + // setShipments(availableDewars); + // } catch (error) { + // console.error('Error fetching shipments:', error); + // // Optionally display the error to the user in the UI + // } + // }; + // + // fetchEvents(); + // fetchShipments(); + //}, []); + const handleEventClick = (eventInfo: any) => { const clickedEventId = eventInfo.event.id; setSelectedEventId(clickedEventId); @@ -137,15 +159,6 @@ const Calendar: React.FC = () => { })); }; - const handleDewarSelection = (dewarId: string) => { - setSelectedDewars(prevSelectedDewars => { - if (prevSelectedDewars.includes(dewarId)) { - return prevSelectedDewars.filter(id => id !== dewarId); // Remove if already selected - } else { - return [...prevSelectedDewars, dewarId]; // Add if not selected - } - }); - }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -173,11 +186,151 @@ const Calendar: React.FC = () => { }); setSelectedDewars([]); // Reset selected dewars }; + const assignDewarToBeamtime = async (dewarId: number, beamtimeId: number) => { + return DewarsService.assignDewarToBeamtime(dewarId, beamtimeId); + }; + + const unassignDewarFromBeamtime = async (dewarId: number) => { + // Pass "0" as the special value (if that's how you unassign) + return DewarsService.assignDewarToBeamtime(dewarId, 0); // or whatever your backend supports + }; + + const assignPuckToBeamtime = async (puckId: number, beamtimeId: number) => { + return PucksService.assignPuckToBeamtime(puckId, beamtimeId); + }; + +// For unassignment (beamtime_id = 0) + const unassignPuckFromBeamtime = async (puckId: number) => { + return PucksService.assignPuckToBeamtime(puckId, 0); + }; + + + + function handleDewarAssignment(dewarId: string) { + const event = events.find(e => e.id === selectedEventId); + if (!event) return; + const beamtimeId = event.beamtime_id; + if (beamtimeId == null) { + console.error("No numeric beamtime_id found for selected event:", event); + return; + } + + // Find out if it's already assigned to this shift! + const prev = eventAssociations[selectedEventId] || { dewars: [], pucks: [] }; + const isAssigned = prev.dewars.includes(dewarId); + + if (!isAssigned) { + // Assign it immediately + assignDewarToBeamtime(Number(dewarId), Number(beamtimeId)) + .then(() => { + setEventAssociations(prevAssoc => { + const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] }; + return { + ...prevAssoc, + [selectedEventId]: { + ...updated, + dewars: [...updated.dewars, dewarId] + } + }; + }); + }) + .catch(e => { + console.error("Failed to assign dewar to beamtime", e); + }); + } else { + // Unassign (patch to None) immediately + unassignDewarFromBeamtime(Number(dewarId)) + .then(() => { + setEventAssociations(prevAssoc => { + const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] }; + return { + ...prevAssoc, + [selectedEventId]: { + ...updated, + dewars: updated.dewars.filter(id => id !== dewarId) + } + }; + }); + }) + .catch(e => { + console.error("Failed to unassign dewar from beamtime", e); + }); + } + } + + + function handlePuckAssignment(puckId: string) { + const event = events.find(e => e.id === selectedEventId); + if (!event) return; + const beamtimeId = event.beamtime_id; + if (beamtimeId == null) { + console.error("No numeric beamtime_id found for selected event:", event); + return; + } + + // Find out if it's already assigned to this shift! + const prev = eventAssociations[selectedEventId] || { dewars: [], pucks: [] }; + const isAssigned = prev.pucks.includes(puckId); + + if (!isAssigned) { + // Assign it immediately + assignPuckToBeamtime(Number(puckId), Number(beamtimeId)) + .then(() => { + setEventAssociations(prevAssoc => { + const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] }; + return { + ...prevAssoc, + [selectedEventId]: { + ...updated, + pucks: [...updated.pucks, puckId] + } + }; + }); + }) + .catch(e => { + console.error("Failed to assign puck to beamtime", e); + }); + } else { + // Unassign (patch to None) immediately + unassignPuckFromBeamtime(Number(puckId)) + .then(() => { + setEventAssociations(prevAssoc => { + const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] }; + return { + ...prevAssoc, + [selectedEventId]: { + ...updated, + pucks: updated.pucks.filter(id => id !== puckId) + } + }; + }); + }) + .catch(e => { + console.error("Failed to unassign puck from beamtime", e); + }); + } + } + + function findAssociatedEventForDewar(dewarId: string): string | undefined { + for (const [eventId, assoc] of Object.entries(eventAssociations)) { + if (assoc.dewars.includes(dewarId)) return eventId; + } + } + function findAssociatedEventForPuck(puckId: string): string | undefined { + for (const [eventId, assoc] of Object.entries(eventAssociations)) { + if (assoc.pucks.includes(puckId)) return eventId; + } + } + const eventContent = (eventInfo: any) => { const beamline = eventInfo.event.extendedProps.beamline || 'Unknown'; const isSelected = selectedEventId === eventInfo.event.id; const isSubmitted = eventInfo.event.extendedProps.isSubmitted; + const hasAssociations = + eventAssociations[eventInfo.event.id]?.dewars.length > 0 || + eventAssociations[eventInfo.event.id]?.pucks.length > 0; + const backgroundColor = isSubmitted ? darkenColor(beamlineColors[beamline] || beamlineColors.Unknown, -20) @@ -203,6 +356,7 @@ const Calendar: React.FC = () => { }} > {eventInfo.event.title} + {hasAssociations && 🧊} ); }; @@ -232,17 +386,64 @@ const Calendar: React.FC = () => {

Select Dewars

    - {shipments.map(dewar => ( -
  • - handleDewarSelection(dewar.id)} - /> - -
  • - ))} + {shipments.map(dewar => { + // Are *all* pucks assigned to this event? (Assigned at Dewar level) + const thisEvent = eventAssociations[selectedEventId] || {dewars: [], pucks: []}; + const dewarAssigned = thisEvent.dewars.includes(dewar.id); + const puckAssignments = thisEvent.pucks; + + return ( +
  • + + {!dewarAssigned && dewar.pucks && dewar.pucks.length > 0 && ( +
      + {(dewar.pucks || []).map(puck => { + const isAssigned = + !!eventAssociations[selectedEventId]?.pucks.includes(puck.id); + + return ( +
      + {puck.name} + + {/* Optionally show assignment info, as before */} + {findAssociatedEventForPuck(puck.id) && ( + ← Assigned to shift: {findAssociatedEventForPuck(puck.id)} + )} +
      + ); + })} + +
    + )} + {/* Show associated shift, if any */} + {findAssociatedEventForDewar(dewar.id) && ( + ← Assigned to shift: {findAssociatedEventForDewar(dewar.id)} + )} +
  • + ); + })}

User Information

diff --git a/frontend/src/styles/Calendar.css b/frontend/src/styles/Calendar.css index 50a7f34..928a4d0 100644 --- a/frontend/src/styles/Calendar.css +++ b/frontend/src/styles/Calendar.css @@ -1,46 +1,17 @@ -.calendar-container { - width: 80%; - margin: 0 auto; -} - -/* Styling each day cell */ -.fc-daygrid-day-frame { - position: relative; /* Ensure positioning for child elements */ - border: 1px solid #e0e0e0; /* Grid cell border for better visibility */ -} - -/* Event styling */ -.fc-event { - border-radius: 3px; /* Rounded corners for events */ - padding: 4px; /* Padding for events */ - font-size: 12px; /* Font size for event text */ - cursor: pointer; /* Pointer cursor for events */ - box-sizing: border-box; /* Include padding in the width/height */ -} - -/* Selected event styling */ -.fc-event-selected { - border: 2px solid black; /* Border for selected events */ -} - -/* Optional: Add hover effect for events */ -.fc-event:hover { - background-color: #FF7043; /* Change color on hover */ -} - -.event-details { - margin-top: 20px; - padding: 15px; - border: 1px solid #ccc; - border-radius: 5px; - background-color: #f9f9f9; -} - -.event-details h3 { - margin: 0 0 10px; -} - -.event-details label { - display: block; - margin-bottom: 10px; -} +.fc-event-shift { + position: absolute !important; /* Enables proper alignment */ + font-size: 12px; /* Text size for better clarity */ + line-height: 1.2; /* Improve readability */ + height: auto !important; /* Flexible height based on content */ + min-height: 25px; /* Ensure adequate space vertically */ + width: 28% !important; /* Prevent events from spanning full cell width */ + border: 1px solid #555; /* Consistent event border */ + border-radius: 4px; /* Rounded corners */ + background-color: rgba(255, 255, 255, 0.9); /* Default background */ + white-space: nowrap; /* Prevent text wrapping */ + overflow: hidden; /* Hide overflowing content */ + text-overflow: ellipsis; /* Show '...' for long titles */ + display: flex; /* Align content vertically and horizontally */ + justify-content: center; /* Center horizontal alignment */ + align-items: center; /* Center vertical alignment */ +} \ No newline at end of file