diff --git a/backend/app/crud.py b/backend/app/crud.py index 44af03a39b1cb6a4ac4bdc822383fe0c9813b6b1..2002b76ec5d1fc0941678b9b824264a9f1f25ba2 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -8,7 +8,7 @@ def get_shipments(db: Session): shipments = ( db.query(Shipment) .options( - joinedload(Shipment.contact_person), + joinedload(Shipment.contact), joinedload(Shipment.return_address), joinedload(Shipment.proposal), joinedload(Shipment.dewars), @@ -30,7 +30,7 @@ def get_shipment_by_id(db: Session, id: int): shipment = ( db.query(Shipment) .options( - joinedload(Shipment.contact_person), + joinedload(Shipment.contact), joinedload(Shipment.return_address), joinedload(Shipment.proposal), joinedload(Shipment.dewars), diff --git a/backend/app/data/data.py b/backend/app/data/data.py index 55887e8ea8ba1cfc5ca22bb2cfcf184ef41a7fdc..d5d494aa62d8c970dc1d40cda6978b54eb209529 100644 --- a/backend/app/data/data.py +++ b/backend/app/data/data.py @@ -1,5 +1,5 @@ from app.models import ( - ContactPerson, + Contact, Address, Dewar, Proposal, @@ -34,71 +34,81 @@ serial_numbers = [ # Define contact persons contacts = [ - ContactPerson( + Contact( id=1, + pgroups="p20000, p20001", firstname="Frodo", lastname="Baggins", phone_number="123-456-7890", email="frodo.baggins@lotr.com", ), - ContactPerson( + Contact( id=2, + pgroups="p20000, p20002", firstname="Samwise", lastname="Gamgee", phone_number="987-654-3210", email="samwise.gamgee@lotr.com", ), - ContactPerson( + Contact( id=3, + pgroups="p20001, p20002", firstname="Aragorn", lastname="Elessar", phone_number="123-333-4444", email="aragorn.elessar@lotr.com", ), - ContactPerson( + Contact( id=4, + pgroups="p20003, p20004", firstname="Legolas", lastname="Greenleaf", phone_number="555-666-7777", email="legolas.greenleaf@lotr.com", ), - ContactPerson( + Contact( id=5, + pgroups="p20002, p20003", firstname="Gimli", lastname="Son of Gloin", phone_number="888-999-0000", email="gimli.sonofgloin@lotr.com", ), - ContactPerson( + Contact( id=6, + pgroups="p20001, p20002", firstname="Gandalf", lastname="The Grey", phone_number="222-333-4444", email="gandalf.thegrey@lotr.com", ), - ContactPerson( + Contact( id=7, + pgroups="p20000, p20004", firstname="Boromir", lastname="Son of Denethor", phone_number="111-222-3333", email="boromir.sonofdenethor@lotr.com", ), - ContactPerson( + Contact( id=8, + pgroups="p20001, p20002", firstname="Galadriel", lastname="Lady of Lothlórien", phone_number="444-555-6666", email="galadriel.lothlorien@lotr.com", ), - ContactPerson( + Contact( id=9, + pgroups="p20001, p20004", firstname="Elrond", lastname="Half-elven", phone_number="777-888-9999", email="elrond.halfelven@lotr.com", ), - ContactPerson( + Contact( id=10, + pgroups="p20004, p20006", firstname="Eowyn", lastname="Shieldmaiden of Rohan", phone_number="000-111-2222", @@ -184,7 +194,7 @@ dewars = [ dewar_serial_number_id=2, tracking_number="TRACK123", return_address_id=1, - contact_person_id=1, + contact_id=1, status="Ready for Shipping", ready_date=datetime.strptime("2023-09-30", "%Y-%m-%d"), shipping_date=None, @@ -199,7 +209,7 @@ dewars = [ dewar_serial_number_id=1, tracking_number="TRACK124", return_address_id=2, - contact_person_id=2, + contact_id=2, status="In Preparation", ready_date=None, shipping_date=None, @@ -214,7 +224,7 @@ dewars = [ dewar_serial_number_id=3, tracking_number="TRACK125", return_address_id=1, - contact_person_id=3, + contact_id=3, status="Not Shipped", ready_date=datetime.strptime("2024-01-01", "%Y-%m-%d"), shipping_date=None, @@ -229,7 +239,7 @@ dewars = [ dewar_serial_number_id=4, tracking_number="", return_address_id=1, - contact_person_id=3, + contact_id=3, status="Delayed", ready_date=datetime.strptime("2024-01-01", "%Y-%m-%d"), shipping_date=datetime.strptime("2024-01-02", "%Y-%m-%d"), @@ -244,7 +254,7 @@ dewars = [ dewar_serial_number_id=1, tracking_number="", return_address_id=1, - contact_person_id=3, + contact_id=3, status="Returned", arrival_date=datetime.strptime("2024-01-03", "%Y-%m-%d"), returning_date=datetime.strptime("2024-01-07", "%Y-%m-%d"), @@ -277,7 +287,7 @@ shipments = [ shipment_date=datetime.strptime("2024-10-10", "%Y-%m-%d"), shipment_name="Shipment from Mordor", shipment_status="Delivered", - contact_person_id=2, + contact_id=2, proposal_id=3, return_address_id=1, comments="Handle with care", @@ -288,7 +298,7 @@ shipments = [ shipment_date=datetime.strptime("2024-10-24", "%Y-%m-%d"), shipment_name="Shipment from Mordor", shipment_status="In Transit", - contact_person_id=4, + contact_id=4, proposal_id=4, return_address_id=2, comments="Contains the one ring", @@ -299,7 +309,7 @@ shipments = [ shipment_date=datetime.strptime("2024-10-28", "%Y-%m-%d"), shipment_name="Shipment from Mordor", shipment_status="In Transit", - contact_person_id=5, + contact_id=5, proposal_id=5, return_address_id=1, comments="Contains the one ring", diff --git a/backend/app/database.py b/backend/app/database.py index bdd244a7698e472268037c64433a0222df98895a..f3dd8ed073e91f4ab69536c09547e76b27c1068a 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -80,7 +80,7 @@ def load_sample_data(session: Session): ) # If any data exists, don't reseed - if session.query(models.ContactPerson).first(): + if session.query(models.Contact).first(): return session.add_all( diff --git a/backend/app/models.py b/backend/app/models.py index 015a3ed517c88b527ea441a87792982a8706bc8a..925be64372b06e0e9cabb37ac4f4191cd582492d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -21,25 +21,27 @@ class Shipment(Base): shipment_date = Column(Date) shipment_status = Column(String(255)) comments = Column(String(200), nullable=True) - contact_person_id = Column(Integer, ForeignKey("contact_persons.id")) + contact_id = Column(Integer, ForeignKey("contacts.id")) return_address_id = Column(Integer, ForeignKey("addresses.id")) proposal_id = Column(Integer, ForeignKey("proposals.id"), nullable=True) - contact_person = relationship("ContactPerson", back_populates="shipments") + contact = relationship("Contact", back_populates="shipments") return_address = relationship("Address", back_populates="shipments") proposal = relationship("Proposal", back_populates="shipments") dewars = relationship("Dewar", back_populates="shipment") -class ContactPerson(Base): - __tablename__ = "contact_persons" +class Contact(Base): + __tablename__ = "contacts" id = Column(Integer, primary_key=True, index=True, autoincrement=True) - firstname = Column(String(255)) + status = Column(String(255), default="active") + pgroups = Column(String(255), nullable=False) + firstname = Column(String(255), nullable=False) lastname = Column(String(255)) phone_number = Column(String(255)) email = Column(String(255)) - shipments = relationship("Shipment", back_populates="contact_person") + shipments = relationship("Shipment", back_populates="contact") class Address(Base): @@ -91,11 +93,11 @@ class Dewar(Base): unique_id = Column(String(255), unique=True, index=True, nullable=True) shipment_id = Column(Integer, ForeignKey("shipments.id")) return_address_id = Column(Integer, ForeignKey("addresses.id")) - contact_person_id = Column(Integer, ForeignKey("contact_persons.id")) + contact_id = Column(Integer, ForeignKey("contacts.id")) shipment = relationship("Shipment", back_populates="dewars") return_address = relationship("Address") - contact_person = relationship("ContactPerson") + contact = relationship("Contact") pucks = relationship("Puck", back_populates="dewar") dewar_type = relationship("DewarType") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 06abfaca8af004c4795cb00648f8beff40cd7f01..8db84e8d4758159d244d13253c4e0eaeb946cd1f 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -1,5 +1,5 @@ -from .address import protected_router as address_router -from .contact import router as contact_router +from .address import address_router +from .contact import contact_router from .proposal import router as proposal_router from .dewar import router as dewar_router from .shipment import router as shipment_router diff --git a/backend/app/routers/address.py b/backend/app/routers/address.py index 303902f6cc183158a879154bf435faa60acfc743..f79ff257d50ab566746bcb23aaa7bcc9626b1d0d 100644 --- a/backend/app/routers/address.py +++ b/backend/app/routers/address.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status, Query +from fastapi import Depends, HTTPException, status, Query, APIRouter from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import List @@ -12,10 +12,11 @@ from app.schemas import ( ) from app.models import Address as AddressModel from app.dependencies import get_db -from app.routers.protected_router import protected_router +address_router = APIRouter() -@protected_router.get("/", response_model=List[AddressSchema]) + +@address_router.get("/", response_model=List[AddressSchema]) async def get_return_addresses( active_pgroup: str = Query(...), db: Session = Depends(get_db), @@ -36,7 +37,7 @@ async def get_return_addresses( return user_addresses -@protected_router.get("/all", response_model=List[AddressSchema]) +@address_router.get("/all", response_model=List[AddressSchema]) async def get_all_addresses( db: Session = Depends(get_db), current_user: loginData = Depends(get_current_user), @@ -52,7 +53,7 @@ async def get_all_addresses( return user_addresses -@protected_router.post( +@address_router.post( "/", response_model=AddressSchema, status_code=status.HTTP_201_CREATED ) async def create_return_address(address: AddressCreate, db: Session = Depends(get_db)): @@ -81,7 +82,7 @@ async def create_return_address(address: AddressCreate, db: Session = Depends(ge return db_address -@protected_router.put("/{address_id}", response_model=AddressSchema) +@address_router.put("/{address_id}", response_model=AddressSchema) async def update_return_address( address_id: int, address: AddressUpdate, db: Session = Depends(get_db) ): @@ -140,7 +141,7 @@ async def update_return_address( return new_address -@protected_router.delete("/{address_id}", status_code=status.HTTP_204_NO_CONTENT) +@address_router.delete("/{address_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_return_address(address_id: int, db: Session = Depends(get_db)): db_address = db.query(AddressModel).filter(AddressModel.id == address_id).first() if not db_address: diff --git a/backend/app/routers/contact.py b/backend/app/routers/contact.py index d64d11724e28152d169ce4e2efffbc33de5538d5..fe0b059bc87d0df76b7879fb96467a44e3cc81f9 100644 --- a/backend/app/routers/contact.py +++ b/backend/app/routers/contact.py @@ -1,36 +1,83 @@ -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, Query from sqlalchemy.orm import Session +from sqlalchemy import or_ from typing import List -from app.schemas import ContactPerson, ContactPersonCreate, ContactPersonUpdate -from app.models import ContactPerson as ContactPersonModel + +from app.schemas import Contact, ContactCreate, ContactUpdate, loginData +from app.models import Contact as ContactModel from app.dependencies import get_db +from app.routers.auth import get_current_user + +contact_router = APIRouter() -router = APIRouter() +# GET /contacts: Retrieve active contacts from the active_pgroup +@contact_router.get("/", response_model=List[Contact]) +async def get_contacts( + active_pgroup: str = Query(...), + db: Session = Depends(get_db), + current_user: loginData = Depends(get_current_user), +): + # Validate that the active_pgroup belongs to the user + if active_pgroup not in current_user.pgroups: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid pgroup provided.", + ) -# Existing routes -@router.get("/", response_model=List[ContactPerson]) -async def get_contacts(db: Session = Depends(get_db)): - return db.query(ContactPersonModel).all() + # Query for active contacts in the active_pgroup + contacts = ( + db.query(ContactModel) + .filter( + ContactModel.pgroups.like(f"%{active_pgroup}%"), + ContactModel.status == "active", + ) + .all() + ) + return contacts -@router.post("/", response_model=ContactPerson, status_code=status.HTTP_201_CREATED) -async def create_contact(contact: ContactPersonCreate, db: Session = Depends(get_db)): +# GET /contacts/all: Retrieve all contacts from the user's pgroups +@contact_router.get("/all", response_model=List[Contact]) +async def get_all_contacts( + db: Session = Depends(get_db), + current_user: loginData = Depends(get_current_user), +): + # Query for all contacts belonging to any of the user's pgroups + user_pgroups = current_user.pgroups + filters = [ContactModel.pgroups.like(f"%{pgroup}%") for pgroup in user_pgroups] + contacts = db.query(ContactModel).filter(or_(*filters)).all() + return contacts + + +@contact_router.post("/", response_model=Contact, status_code=status.HTTP_201_CREATED) +async def create_contact( + contact: ContactCreate, # Body parameter ONLY + db: Session = Depends(get_db), # Secondary dependency for database access +): + # Check if a contact with the same email already exists in this pgroup if ( - db.query(ContactPersonModel) - .filter(ContactPersonModel.email == contact.email) + db.query(ContactModel) + .filter( + ContactModel.email == contact.email, + ContactModel.pgroups.like(f"%{contact.pgroups}%"), + ContactModel.status == "active", + ) .first() ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="This contact already exists.", + detail="This contact already exists in the provided pgroup.", ) - db_contact = ContactPersonModel( + # Create a new contact + db_contact = ContactModel( firstname=contact.firstname, lastname=contact.lastname, phone_number=contact.phone_number, email=contact.email, + pgroups=contact.pgroups, # Use the pgroups from the body + status="active", # Newly created contacts will be active ) db.add(db_contact) db.commit() @@ -38,34 +85,78 @@ async def create_contact(contact: ContactPersonCreate, db: Session = Depends(get return db_contact -# New routes -@router.put("/{contact_id}", response_model=ContactPerson) +# PUT /contacts/{contact_id}: Update a contact +@contact_router.put("/{contact_id}", response_model=Contact) async def update_contact( - contact_id: int, contact: ContactPersonUpdate, db: Session = Depends(get_db) + contact_id: int, + contact: ContactUpdate, + db: Session = Depends(get_db), ): - db_contact = ( - db.query(ContactPersonModel).filter(ContactPersonModel.id == contact_id).first() - ) + # Retrieve the existing contact + db_contact = db.query(ContactModel).filter(ContactModel.id == contact_id).first() if not db_contact: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found." + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found.", + ) + # Normalize existing and new pgroups (remove whitespace, handle case + # sensitivity if needed) + existing_pgroups = ( + set(p.strip() for p in db_contact.pgroups.split(",") if p.strip()) + if db_contact.pgroups + else set() + ) + new_pgroups = ( + set(p.strip() for p in contact.pgroups.split(",") if p.strip()) + if contact.pgroups + else set() + ) + + # Check if any old pgroups are being removed (strict validation against removal) + if not new_pgroups.issuperset(existing_pgroups): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Modifying pgroups to remove existing ones is not allowed.", ) - for key, value in contact.dict(exclude_unset=True).items(): - setattr(db_contact, key, value) + + combined_pgroups = existing_pgroups.union(new_pgroups) + + # Mark the old contact as inactive + db_contact.status = "inactive" db.commit() db.refresh(db_contact) - return db_contact - -@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_contact(contact_id: int, db: Session = Depends(get_db)): - db_contact = ( - db.query(ContactPersonModel).filter(ContactPersonModel.id == contact_id).first() + # Create a new contact with the updated data + new_contact = ContactModel( + firstname=contact.firstname or db_contact.firstname, + lastname=contact.lastname or db_contact.lastname, + phone_number=contact.phone_number or db_contact.phone_number, + email=contact.email or db_contact.email, + pgroups=",".join(combined_pgroups), # Use the active_pgroup + status="active", # Newly created contacts will be active ) + db.add(new_contact) + db.commit() + db.refresh(new_contact) + + return new_contact + + +# DELETE /contacts/{contact_id}: Mark a contact as inactive +@contact_router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_contact( + contact_id: int, + db: Session = Depends(get_db), +): + # Retrieve the existing contact + db_contact = db.query(ContactModel).filter(ContactModel.id == contact_id).first() if not db_contact: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found." + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found.", ) - db.delete(db_contact) + + # Mark the contact as inactive + db_contact.status = "inactive" db.commit() return diff --git a/backend/app/routers/protected_router.py b/backend/app/routers/protected_router.py index 62b03139dc5fc1bd13f162d826495f6025d07ad8..bb6a3877a968c4f604de227b459a8441d8680efd 100644 --- a/backend/app/routers/protected_router.py +++ b/backend/app/routers/protected_router.py @@ -1,7 +1,12 @@ from fastapi import APIRouter, Depends from app.routers.auth import get_current_user +from app.routers.address import address_router +from app.routers.contact import contact_router protected_router = APIRouter( dependencies=[Depends(get_current_user)] # Applies to all routes ) + +protected_router.include_router(address_router, prefix="/addresses", tags=["addresses"]) +protected_router.include_router(contact_router, prefix="/contacts", tags=["contacts"]) diff --git a/backend/app/routers/shipment.py b/backend/app/routers/shipment.py index 1cb3cd0271a667fea796d54d19d769e9a5eb2144..f0e07e81c21f49f1653a52f20d03d5535497da97 100644 --- a/backend/app/routers/shipment.py +++ b/backend/app/routers/shipment.py @@ -7,7 +7,7 @@ import json from app.models import ( Shipment as ShipmentModel, - ContactPerson as ContactPersonModel, + Contact as ContactModel, Address as AddressModel, Proposal as ProposalModel, Dewar as DewarModel, @@ -19,7 +19,7 @@ from app.schemas import ( ShipmentCreate, UpdateShipmentComments, Shipment as ShipmentSchema, - ContactPerson as ContactPersonSchema, + Contact as ContactSchema, Sample as SampleSchema, DewarSchema, ) @@ -71,10 +71,8 @@ async def get_dewars_by_shipment_id(shipment_id: int, db: Session = Depends(get_ @router.post("", response_model=ShipmentSchema, status_code=status.HTTP_201_CREATED) async def create_shipment(shipment: ShipmentCreate, db: Session = Depends(get_db)): - contact_person = ( - db.query(ContactPersonModel) - .filter(ContactPersonModel.id == shipment.contact_person_id) - .first() + contact = ( + db.query(ContactModel).filter(ContactModel.id == shipment.contact_id).first() ) return_address = ( db.query(AddressModel) @@ -85,7 +83,7 @@ async def create_shipment(shipment: ShipmentCreate, db: Session = Depends(get_db db.query(ProposalModel).filter(ProposalModel.id == shipment.proposal_id).first() ) - if not (contact_person or return_address or proposal): + if not (contact or return_address or proposal): raise HTTPException(status_code=404, detail="Associated entity not found") db_shipment = ShipmentModel( @@ -93,7 +91,7 @@ async def create_shipment(shipment: ShipmentCreate, db: Session = Depends(get_db shipment_date=shipment.shipment_date, shipment_status=shipment.shipment_status, comments=shipment.comments, - contact_person_id=contact_person.id, + contact_id=contact.id, return_address_id=return_address.id, proposal_id=proposal.id, ) @@ -189,8 +187,8 @@ async def update_shipment( # Validate relationships by IDs contact_person = ( - db.query(ContactPersonModel) - .filter(ContactPersonModel.id == updated_shipment.contact_person_id) + db.query(ContactModel) + .filter(ContactModel.id == updated_shipment.contact_person_id) .first() ) return_address = ( @@ -225,9 +223,7 @@ async def update_shipment( for key, value in update_fields.items(): if key == "contact_person_id": contact_person = ( - db.query(ContactPersonModel) - .filter(ContactPersonModel.id == value) - .first() + db.query(ContactModel).filter(ContactModel.id == value).first() ) if not contact_person: raise HTTPException( @@ -342,9 +338,9 @@ async def remove_dewar_from_shipment( return shipment -@router.get("/contact_persons", response_model=List[ContactPersonSchema]) +@router.get("/contact_persons", response_model=List[ContactSchema]) async def get_shipment_contact_persons(db: Session = Depends(get_db)): - contact_persons = db.query(ContactPersonModel).all() + contact_persons = db.query(ContactModel).all() return contact_persons diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b8e4a5363efd24cbb1066028a311128641eb8b8d..411fa3dcfa7e7b2ffe01fddac20a0a4dcaa87550 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -366,25 +366,24 @@ class Results(BaseModel): pass -class ContactPersonBase(BaseModel): +class ContactCreate(BaseModel): + pgroups: str firstname: str lastname: str phone_number: str email: EmailStr -class ContactPersonCreate(ContactPersonBase): - pass - - -class ContactPerson(ContactPersonBase): +class Contact(ContactCreate): id: int + status: str = "active" class Config: from_attributes = True -class ContactPersonUpdate(BaseModel): +class ContactUpdate(BaseModel): + pgroups: str firstname: Optional[str] = None lastname: Optional[str] = None phone_number: Optional[str] = None @@ -510,7 +509,7 @@ class DewarBase(BaseModel): shipping_date: Optional[date] arrival_date: Optional[date] returning_date: Optional[date] - contact_person_id: Optional[int] + contact_id: Optional[int] return_address_id: Optional[int] pucks: List[PuckCreate] = [] @@ -525,7 +524,7 @@ class DewarCreate(DewarBase): class Dewar(DewarBase): id: int shipment_id: Optional[int] - contact_person: Optional[ContactPerson] + contact: Optional[Contact] return_address: Optional[Address] pucks: List[Puck] = [] # List of pucks within this dewar @@ -544,7 +543,7 @@ class DewarUpdate(BaseModel): shipping_date: Optional[date] = None arrival_date: Optional[date] = None returning_date: Optional[date] = None - contact_person_id: Optional[int] = None + contact_id: Optional[int] = None address_id: Optional[int] = None @@ -553,7 +552,7 @@ class DewarSchema(BaseModel): dewar_name: str tracking_number: str status: str - contact_person_id: int + contact_id: int return_address_id: int class Config: @@ -574,7 +573,7 @@ class Shipment(BaseModel): shipment_date: date shipment_status: str comments: Optional[str] - contact_person: Optional[ContactPerson] + contact: Optional[Contact] return_address: Optional[Address] proposal: Optional[Proposal] dewars: List[Dewar] = [] @@ -588,7 +587,7 @@ class ShipmentCreate(BaseModel): shipment_date: date shipment_status: str comments: Optional[constr(max_length=200)] - contact_person_id: int + contact_id: int return_address_id: int proposal_id: int dewars: List[DewarCreate] = [] @@ -621,7 +620,7 @@ class SlotSchema(BaseModel): retrievedTimestamp: Optional[str] beamlineLocation: Optional[str] shipment_name: Optional[str] - contact_person: Optional[str] + contact: Optional[str] local_contact: Optional[str] class Config: diff --git a/backend/main.py b/backend/main.py index b6c62a3b89027f377fd7d381cceca89ef6a2417c..3d3b3c220b17e6e72e19aea970d594f538e46196 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,8 +6,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app import ssl_heidi from app.routers import ( - address, - contact, proposal, dewar, shipment, @@ -157,8 +155,6 @@ def on_startup(): # Include routers with correct configuration app.include_router(protected_router, prefix="/protected", tags=["protected"]) app.include_router(auth.router, prefix="/auth", tags=["auth"]) -app.include_router(contact.router, prefix="/contacts", tags=["contacts"]) -app.include_router(address.protected_router, prefix="/addresses", tags=["addresses"]) app.include_router(proposal.router, prefix="/proposals", tags=["proposals"]) app.include_router(dewar.router, prefix="/dewars", tags=["dewars"]) app.include_router(shipment.router, prefix="/shipments", tags=["shipments"]) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e0b0d5a5bf10006067fcedffc6937f98aecc8a27..e939409c35ba2135d37aa308f08c4894969f0f47 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -33,4 +33,7 @@ def test_protected_route(): headers = {"Authorization": f"Bearer {token}"} response = client.get("/auth/protected-route", headers=headers) assert response.status_code == 200 - assert response.json() == {"username": "testuser", "pgroups": [20000, 20001, 20003]} + assert response.json() == { + "username": "testuser", + "pgroups": [20000, 20001, 20002, 20003], + } diff --git a/frontend/public/shipmentsdb.json b/frontend/public/shipmentsdb.json index 1c0914c54571c091c3a55e69a56d85914c1d8669..fd8a6f89414272f93b1b8cc581abef67434e2eae 100644 --- a/frontend/public/shipmentsdb.json +++ b/frontend/public/shipmentsdb.json @@ -6,7 +6,7 @@ "number_of_dewars": 2, "shipment_status": "In Transit", "shipment_date": "2024-01-15", - "contact_person": [ + "contact": [ { "name": "Alice Johnson", "id": "alice" } ], "dewars": [ @@ -19,7 +19,7 @@ "return_address": [ { "address": "123 Main St, Anytown, USA", "id": "address1" } ], - "contact_person": [ + "contact": [ { "name": "Alice Johnson", "id": "alice" } ], "status": "in preparation", @@ -40,7 +40,7 @@ "return_address": [ { "address": "123 Main St, Anytown, USA", "id": "address1" } ], - "contact_person": [ + "contact": [ { "name": "Alice Johnson", "id": "alice" } ], "status": "in preparation", @@ -60,7 +60,7 @@ "number_of_dewars": 3, "shipment_status": "In Transit", "shipment_date": "2024-02-20", - "contact_person": [ + "contact": [ { "name": "Bob Smith", "id": "bob" } ], "dewars": [ @@ -70,7 +70,7 @@ "tracking_number": "TRACK987654", "number_of_pucks": 5, "number_of_samples": 30, - "contact_person": [ + "contact": [ { "name": "Bob Smith", "id": "bob" } ], "return_address": [ @@ -91,7 +91,7 @@ "tracking_number": "TRACK876543", "number_of_pucks": 6, "number_of_samples": 36, - "contact_person": [ + "contact": [ { "name": "Bob Smith", "id": "bob" } ], "return_address": [ @@ -112,7 +112,7 @@ "tracking_number": "TRACK765432", "number_of_pucks": 4, "number_of_samples": 24, - "contact_person": [ + "contact": [ { "name": "Bob Smith", "id": "bob" } ], "return_address": [ @@ -135,7 +135,7 @@ "number_of_dewars": 5, "shipment_status": "Pending", "shipment_date": "2024-03-10", - "contact_person": [ + "contact": [ { "name": "Charlie Brown", "id": "charlie" } ], "dewars": [ @@ -145,7 +145,7 @@ "tracking_number": "TRACK112233", "number_of_pucks": 7, "number_of_samples": 42, - "contact_person": [ + "contact": [ { "name": "Charlie Brown", "id": "charlie" } ], "return_address": [ @@ -166,7 +166,7 @@ "tracking_number": "TRACK223344", "number_of_pucks": 5, "number_of_samples": 30, - "contact_person": [ + "contact": [ { "name": "Charlie Brown", "id": "charlie" } ], "return_address": [ @@ -187,7 +187,7 @@ "tracking_number": "TRACK334455", "number_of_pucks": 8, "number_of_samples": 48, - "contact_person": [ + "contact": [ { "name": "Charlie Brown", "id": "charlie" } ], "return_address": [ @@ -208,7 +208,7 @@ "tracking_number": "TRACK445566", "number_of_pucks": 6, "number_of_samples": 36, - "contact_person": [ + "contact": [ { "name": "Charlie Brown", "id": "charlie" } ], "return_address": [ @@ -229,7 +229,7 @@ "tracking_number": "TRACK556677", "number_of_pucks": 4, "number_of_samples": 24, - "contact_person": [ + "contact": [ { "name": "Charlie Brown", "id": "charlie" } ], "return_address": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04170ca648ee6223933c8bc711860098e2378ea2..23faa19cf127668b978c52fa46d59de0f508f9ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -90,7 +90,7 @@ const App: React.FC = () => { <AddressManager pgroups={pgroups} activePgroup={activePgroup} /> </Modal> <Modal open={openContactsManager} onClose={handleCloseContactsManager} title="Contacts Management"> - <ContactsManager /> + <ContactsManager pgroups={pgroups} activePgroup={activePgroup} /> </Modal> </Router> ); diff --git a/frontend/src/components/DewarDetails.tsx b/frontend/src/components/DewarDetails.tsx index 2ed3359a527e1b25376b883897f531b119e62ce3..903ef89f9c4e0dd5ef7648418d88d9eff663ae3e 100644 --- a/frontend/src/components/DewarDetails.tsx +++ b/frontend/src/components/DewarDetails.tsx @@ -22,7 +22,7 @@ import { Dewar, DewarType, DewarSerialNumber, - ContactPerson, + Contact, Address, ContactsService, AddressesService, @@ -37,14 +37,14 @@ interface DewarDetailsProps { dewar: Dewar; trackingNumber: string; setTrackingNumber: (trackingNumber: string) => void; - initialContactPersons?: ContactPerson[]; + initialContacts?: Contact[]; initialReturnAddresses?: Address[]; - defaultContactPerson?: ContactPerson; + defaultContact?: Contact; defaultReturnAddress?: Address; shipmentId: number; } -interface NewContactPerson { +interface NewContact { id: number; firstName: string; lastName: string; @@ -64,21 +64,21 @@ const DewarDetails: React.FC<DewarDetailsProps> = ({ dewar, trackingNumber, setTrackingNumber, - initialContactPersons = [], + initialContacts = [], initialReturnAddresses = [], - defaultContactPerson, + defaultContact, defaultReturnAddress, shipmentId, }) => { const [localTrackingNumber, setLocalTrackingNumber] = useState(trackingNumber); - const [contactPersons, setContactPersons] = useState(initialContactPersons); + const [contacts, setContacts] = useState(initialContacts); const [returnAddresses, setReturnAddresses] = useState(initialReturnAddresses); - const [selectedContactPerson, setSelectedContactPerson] = useState<string>(''); + const [selectedContact, setSelectedContact] = useState<string>(''); const [selectedReturnAddress, setSelectedReturnAddress] = useState<string>(''); - const [isCreatingContactPerson, setIsCreatingContactPerson] = useState(false); + const [isCreatingContact, setIsCreatingContact] = useState(false); const [isCreatingReturnAddress, setIsCreatingReturnAddress] = useState(false); const [puckStatuses, setPuckStatuses] = useState<string[][]>([]); - const [newContactPerson, setNewContactPerson] = useState<NewContactPerson>({ + const [newContact, setNewContact] = useState<NewContact>({ id: 0, firstName: '', lastName: '', @@ -140,9 +140,9 @@ const DewarDetails: React.FC<DewarDetailsProps> = ({ useEffect(() => { setLocalTrackingNumber(dewar.tracking_number || ''); - const setInitialContactPerson = () => { - setSelectedContactPerson( - dewar.contact_person?.id?.toString() || defaultContactPerson?.id?.toString() || '' + const setInitialContact = () => { + setSelectedContact( + dewar.contact?.id?.toString() || defaultContact?.id?.toString() || '' ); }; @@ -152,7 +152,7 @@ const DewarDetails: React.FC<DewarDetailsProps> = ({ ); }; - setInitialContactPerson(); + setInitialContact(); setInitialReturnAddress(); if (dewar.dewar_type_id) { @@ -161,7 +161,7 @@ const DewarDetails: React.FC<DewarDetailsProps> = ({ if (dewar.dewar_serial_number_id) { setSelectedSerialNumber(dewar.dewar_serial_number_id.toString()); } - }, [dewar, defaultContactPerson, defaultReturnAddress]); + }, [dewar, defaultContact, defaultReturnAddress]); useEffect(() => { const getContacts = async () => { @@ -375,7 +375,7 @@ const DewarDetails: React.FC<DewarDetailsProps> = ({ arrival_date: dewar.arrival_date, returning_date: dewar.returning_date, return_address_id: parseInt(selectedReturnAddress ?? '', 10), - contact_person_id: parseInt(selectedContactPerson ?? '', 10), + contact_id: parseInt(selectedContactPerson ?? '', 10), }; await DewarsService.updateDewarDewarsDewarIdPut(dewarId, payload); diff --git a/frontend/src/components/DewarStepper.tsx b/frontend/src/components/DewarStepper.tsx index 3932cb45f49e7e638d2fdfd89c0e1681d954e239..e717257bb18b97ca5934a37c912d0afc25d19541 100644 --- a/frontend/src/components/DewarStepper.tsx +++ b/frontend/src/components/DewarStepper.tsx @@ -85,7 +85,7 @@ const StepIconComponent: React.FC<StepIconComponentProps> = ({ icon, dewar, isSe returning_date: dewar.returning_date, qrcode: dewar.qrcode, return_address_id: dewar.return_address_id, - contact_person_id: dewar.contact_person_id, + contact_id: dewar.contact_id, }; await DewarsService.updateDewarDewarsDewarIdPut(dewar.id, payload); diff --git a/frontend/src/components/ShipmentDetails.tsx b/frontend/src/components/ShipmentDetails.tsx index 0e232bac45a3155776a628b8d22ccf6ac8e5ed7d..40de626ac5bbfec1b60902cffdefc8012b4c70f0 100644 --- a/frontend/src/components/ShipmentDetails.tsx +++ b/frontend/src/components/ShipmentDetails.tsx @@ -4,7 +4,7 @@ import QRCode from 'react-qr-code'; import DeleteIcon from "@mui/icons-material/Delete"; import CheckIcon from '@mui/icons-material/Check'; import CloseIcon from '@mui/icons-material/Close'; -import { Dewar, DewarsService, Shipment, ContactPerson, ApiError, ShipmentsService } from "../../openapi"; +import { Dewar, DewarsService, Shipment, Contact, ApiError, ShipmentsService } from "../../openapi"; import { SxProps } from "@mui/system"; import CustomStepper from "./DewarStepper"; import DewarDetails from './DewarDetails'; @@ -20,7 +20,7 @@ interface ShipmentDetailsProps { setSelectedDewar: React.Dispatch<React.SetStateAction<Dewar | null>>; setSelectedShipment: React.Dispatch<React.SetStateAction<Shipment | null>>; refreshShipments: () => void; - defaultContactPerson?: ContactPerson; + defaultContact?: Contact; } const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ @@ -48,7 +48,7 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ shipping_date: null, arrival_date: null, returning_date: null, - contact_person_id: selectedShipment?.contact_person?.id, + contact_id: selectedShipment?.contact?.id, return_address_id: selectedShipment?.return_address?.id, }; @@ -59,7 +59,7 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ // Ensure to update the default contact person and return address when the shipment changes setNewDewar((prev) => ({ ...prev, - contact_person_id: selectedShipment?.contact_person?.id, + contact_id: selectedShipment?.contact?.id, return_address_id: selectedShipment?.return_address?.id })); }, [selectedShipment]); @@ -122,7 +122,7 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ ...initialNewDewarState, ...newDewar, dewar_name: newDewar.dewar_name.trim(), - contact_person_id: selectedShipment?.contact_person?.id, + contact_id: selectedShipment?.contact?.id, return_address_id: selectedShipment?.return_address?.id } as Dewar; @@ -179,7 +179,7 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ }; const isCommentsEdited = comments !== initialComments; - const contactPerson = selectedShipment?.contact_person; + const contact = selectedShipment?.contact; return ( <Box sx={{ ...sx, padding: 2, textAlign: 'left' }}> @@ -228,7 +228,7 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ <Box sx={{ marginTop: 2, marginBottom: 2 }}> <Typography variant="h5">{selectedShipment.shipment_name}</Typography> <Typography variant="body1" color="textSecondary"> - Main contact person: {contactPerson ? `${contactPerson.firstname} ${contactPerson.lastname}` : 'N/A'} + Main contact person: {contact ? `${contact.firstname} ${contact.lastname}` : 'N/A'} </Typography> <Typography variant="body1">Number of Pucks: {totalPucks}</Typography> <Typography variant="body1">Number of Samples: {totalSamples}</Typography> @@ -318,7 +318,7 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ <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_person?.firstname ? `${dewar.contact_person.firstname} ${dewar.contact_person.lastname}` : 'N/A'} + Contact Person: {dewar.contact?.firstname ? `${dewar.contact.firstname} ${dewar.contact.lastname}` : 'N/A'} </Typography> </Box> <Box sx={{ @@ -355,9 +355,9 @@ const ShipmentDetails: React.FC<ShipmentDetailsProps> = ({ setTrackingNumber={(value) => { setLocalSelectedDewar((prev) => (prev ? { ...prev, tracking_number: value as string } : prev)); }} - initialContactPersons={localSelectedDewar?.contact_person ? [localSelectedDewar.contact_person] : []} + initialContacts={localSelectedDewar?.contact ? [localSelectedDewar.contact] : []} initialReturnAddresses={localSelectedDewar?.return_address ? [localSelectedDewar.return_address] : []} - defaultContactPerson={localSelectedDewar?.contact_person ?? undefined} + defaultContact={localSelectedDewar?.contact ?? undefined} defaultReturnAddress={localSelectedDewar?.return_address ?? undefined} shipmentId={selectedShipment?.id ?? null} refreshShipments={refreshShipments} diff --git a/frontend/src/components/ShipmentForm.tsx b/frontend/src/components/ShipmentForm.tsx index 4379237acc8903b2a6c617d65ee51f541408e9ff..f973f033d5df2b0f10b754ef0fdb4b3e6472745b 100644 --- a/frontend/src/components/ShipmentForm.tsx +++ b/frontend/src/components/ShipmentForm.tsx @@ -6,7 +6,7 @@ import { import { SelectChangeEvent } from '@mui/material'; import { SxProps } from '@mui/system'; import { - ContactPersonCreate, ContactPerson, Address, AddressCreate, Proposal, ContactsService, AddressesService, ProposalsService, + ContactCreate, Contact, Address, AddressCreate, Proposal, ContactsService, AddressesService, ProposalsService, OpenAPI, ShipmentCreate, ShipmentsService } from '../../openapi'; import { useEffect } from 'react'; @@ -41,21 +41,21 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ onCancel, refreshShipments }) => { const [countrySuggestions, setCountrySuggestions] = React.useState<string[]>([]); - const [contactPersons, setContactPersons] = React.useState<ContactPerson[]>([]); + const [contacts, setContacts] = React.useState<Contact[]>([]); const [returnAddresses, setReturnAddresses] = React.useState<Address[]>([]); const [proposals, setProposals] = React.useState<Proposal[]>([]); - const [isCreatingContactPerson, setIsCreatingContactPerson] = React.useState(false); + const [isCreatingContact, setIsCreatingContact] = React.useState(false); const [isCreatingReturnAddress, setIsCreatingReturnAddress] = React.useState(false); - const [newContactPerson, setNewContactPerson] = React.useState<ContactPersonCreate>({ - firstname: '', lastname: '', phone_number: '', email: '' + const [newContact, setNewContact] = React.useState<ContactCreate>({ + pgroups:'', firstname: '', lastname: '', phone_number: '', email: '' }); const [newReturnAddress, setNewReturnAddress] = React.useState<Omit<Address, 'id'>>({ - pgroup:'', house_number: '', street: '', city: '', state: '', zipcode: '', country: '' + pgroups:'', house_number: '', street: '', city: '', state: '', zipcode: '', country: '' }); const [newShipment, setNewShipment] = React.useState<Partial<ShipmentCreate>>({ shipment_name: '', shipment_status: 'In preparation', comments: '' }); - const [selectedContactPersonId, setSelectedContactPersonId] = React.useState<number | null>(null); + const [selectedContactId, setSelectedContactId] = React.useState<number | null>(null); const [selectedReturnAddressId, setSelectedReturnAddressId] = React.useState<number | null>(null); const [selectedProposalId, setSelectedProposalId] = React.useState<number | null>(null); const [errorMessage, setErrorMessage] = React.useState<string | null>(null); @@ -83,12 +83,17 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ // Fetch necessary data const getContacts = async () => { + if (!activePgroup) { + console.error("Active pgroup is missing."); + setErrorMessage("Active pgroup is missing. Unable to load contacts."); + return; + } try { - const fetchedContacts: ContactPerson[] = - await ContactsService.getContactsContactsGet(); - setContactPersons(fetchedContacts); + const fetchedContacts: Contact[] = + await ContactsService.getContactsProtectedContactsGet(activePgroup); + setContacts(fetchedContacts); } catch { - setErrorMessage('Failed to load contact persons.'); + setErrorMessage('Failed to load contact s.'); } }; @@ -102,7 +107,7 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ try { // Pass activePgroup directly as a string (not as an object) const fetchedAddresses: Address[] = - await AddressesService.getReturnAddressesAddressesGet(activePgroup); + await AddressesService.getReturnAddressesProtectedAddressesGet(activePgroup); setReturnAddresses(fetchedAddresses); } catch (error) { @@ -150,9 +155,9 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ }; const isContactFormValid = () => { - const { firstname, lastname, phone_number, email } = newContactPerson; + const { firstname, lastname, phone_number, email } = newContact; - if (isCreatingContactPerson) { + if (isCreatingContact) { if (!firstname || !lastname || !validateEmail(email) || !validatePhoneNumber(phone_number)) return false; } @@ -173,9 +178,9 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ const { shipment_name } = newShipment; if (!shipment_name) return false; - if (!selectedContactPersonId || !selectedReturnAddressId || !selectedProposalId) return false; + if (!selectedContactId || !selectedReturnAddressId || !selectedProposalId) return false; - if (isCreatingContactPerson && !isContactFormValid()) return false; + if (isCreatingContact && !isContactFormValid()) return false; if (isCreatingReturnAddress && !isAddressFormValid()) return false; return true; @@ -197,7 +202,7 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ shipment_date: new Date().toISOString().split('T')[0], // Remove if date is not required shipment_status: newShipment.shipment_status || 'In preparation', comments: newShipment.comments || '', - contact_person_id: selectedContactPersonId!, + contact_id: selectedContactId!, return_address_id: selectedReturnAddressId!, proposal_id: selectedProposalId!, dewars: newShipment.dewars || [], @@ -217,14 +222,14 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ } }; - const handleContactPersonChange = (event: SelectChangeEvent) => { + const handleContactChange = (event: SelectChangeEvent) => { const value = event.target.value; if (value === 'new') { - setIsCreatingContactPerson(true); - setSelectedContactPersonId(null); + setIsCreatingContact(true); + setSelectedContactId(null); } else { - setIsCreatingContactPerson(false); - setSelectedContactPersonId(parseInt(value)); + setIsCreatingContact(false); + setSelectedContactId(parseInt(value)); } }; @@ -244,33 +249,52 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ setSelectedProposalId(parseInt(value)); }; - const handleSaveNewContactPerson = async () => { - if (!isContactFormValid()) { + const handleSaveNewContact = async () => { + // Validate contact form fields + if (!isContactFormValid(newContact)) { setErrorMessage('Please fill in all new contact person fields correctly.'); return; } - const payload: ContactPersonCreate = { - firstname: newContactPerson.firstname, - lastname: newContactPerson.lastname, - phone_number: newContactPerson.phone_number, - email: newContactPerson.email, + // Ensure activePgroup is available + if (!activePgroup) { + setErrorMessage('Active pgroup is missing. Please try again.'); + return; + } + + // Construct the payload + const payload: ContactCreate = { + pgroups: activePgroup, // Ensure this value is available + firstname: newContact.firstname.trim(), + lastname: newContact.lastname.trim(), + phone_number: newContact.phone_number.trim(), + email: newContact.email.trim(), }; - console.log('Contact Person Payload being sent:', payload); + console.log('Payload being sent:', JSON.stringify(payload, null, 2)); try { - const newPerson: ContactPerson = await ContactsService.createContactContactsPost(payload); - setContactPersons([...contactPersons, newPerson]); - setErrorMessage(null); - setSelectedContactPersonId(newPerson.id); + // Call the API with the correctly constructed payload + const newPerson: Contact = await ContactsService.createContactProtectedContactsPost(payload); + + // Update state on success + setContacts([...contacts, newPerson]); // Add new contact to the list + setErrorMessage(null); // Clear error messages + setSelectedContactId(newPerson.id); // Optionally select the contact + + // Reset form inputs + setNewContact({ pgroups: '', firstname: '', lastname: '', phone_number: '', email: '' }); + setIsCreatingContact(false); } catch (error) { console.error('Failed to create a new contact person:', error); - setErrorMessage('Failed to create a new contact person. Please try again later.'); - } - setNewContactPerson({ firstname: '', lastname: '', phone_number: '', email: '' }); - setIsCreatingContactPerson(false); + // Handle detailed backend error messages if available + if (error.response?.data?.detail) { + setErrorMessage(`Error: ${error.response.data.detail}`); + } else { + setErrorMessage('Failed to create a new contact person. Please try again later.'); + } + } }; const handleSaveNewReturnAddress = async () => { @@ -301,7 +325,7 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ // Call the API with the completed payload try { - const response: Address = await AddressesService.createReturnAddressAddressesPost(payload); + const response: Address = await AddressesService.createReturnAddressProtectedAddressesPost(payload); setReturnAddresses([...returnAddresses, response]); // Update the address state setErrorMessage(null); setSelectedReturnAddressId(response.id); // Set the newly created address ID to the form @@ -311,7 +335,7 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ } // Reset form inputs and close the "Create New Address" form - setNewReturnAddress({ pgroup: '', house_number: '', street: '', city: '', state: '', zipcode: '', country: '' }); + setNewReturnAddress({ pgroups: '', house_number: '', street: '', city: '', state: '', zipcode: '', country: '' }); setIsCreatingReturnAddress(false); }; @@ -342,11 +366,11 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ <FormControl fullWidth required> <InputLabel>Contact Person</InputLabel> <Select - value={selectedContactPersonId ? selectedContactPersonId.toString() : ''} - onChange={handleContactPersonChange} + value={selectedContactId ? selectedContactId.toString() : ''} + onChange={handleContactChange} displayEmpty > - {contactPersons.map((person) => ( + {contacts.map((person) => ( <MenuItem key={person.id} value={person.id.toString()}> {`${person.lastname}, ${person.firstname}`} </MenuItem> @@ -356,21 +380,21 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ </MenuItem> </Select> </FormControl> - {isCreatingContactPerson && ( + {isCreatingContact && ( <> <TextField label="First Name" name="firstname" - value={newContactPerson.firstname} - onChange={(e) => setNewContactPerson({ ...newContactPerson, firstname: e.target.value })} + value={newContact.firstname} + onChange={(e) => setNewContact({ ...newContact, firstname: e.target.value })} fullWidth required /> <TextField label="Last Name" name="lastname" - value={newContactPerson.lastname} - onChange={(e) => setNewContactPerson({ ...newContactPerson, lastname: e.target.value })} + value={newContact.lastname} + onChange={(e) => setNewContact({ ...newContact, lastname: e.target.value })} fullWidth required /> @@ -378,28 +402,28 @@ const ShipmentForm: React.FC<ShipmentFormProps> = ({ label="Phone" name="phone_number" type="tel" - value={newContactPerson.phone_number} - onChange={(e) => setNewContactPerson({ ...newContactPerson, phone_number: e.target.value })} + value={newContact.phone_number} + onChange={(e) => setNewContact({ ...newContact, phone_number: e.target.value })} fullWidth required - error={!validatePhoneNumber(newContactPerson.phone_number)} - helperText={!validatePhoneNumber(newContactPerson.phone_number) ? 'Invalid phone number' : ''} + error={!validatePhoneNumber(newContact.phone_number)} + helperText={!validatePhoneNumber(newContact.phone_number) ? 'Invalid phone number' : ''} /> <TextField label="Email" name="email" type="email" - value={newContactPerson.email} - onChange={(e) => setNewContactPerson({ ...newContactPerson, email: e.target.value })} + value={newContact.email} + onChange={(e) => setNewContact({ ...newContact, email: e.target.value })} fullWidth required - error={!validateEmail(newContactPerson.email)} - helperText={!validateEmail(newContactPerson.email) ? 'Invalid email' : ''} + error={!validateEmail(newContact.email)} + helperText={!validateEmail(newContact.email) ? 'Invalid email' : ''} /> <Button variant="contained" color="primary" - onClick={handleSaveNewContactPerson} + onClick={handleSaveNewContact} disabled={!isContactFormValid()} > Save New Contact Person diff --git a/frontend/src/components/SpreadsheetTable.tsx b/frontend/src/components/SpreadsheetTable.tsx index ac9b961f79540986fc11799dde7732537f9564dc..997e147c590c2316586659f5853119eeed183248 100644 --- a/frontend/src/components/SpreadsheetTable.tsx +++ b/frontend/src/components/SpreadsheetTable.tsx @@ -65,7 +65,7 @@ const SpreadsheetTable = ({ shipping_date: null, arrival_date: null, returning_date: null, - contact_person_id: selectedShipment?.contact_person?.id, + contact_id: selectedShipment?.contact?.id, return_address_id: selectedShipment?.return_address?.id, dewar_name: '', tracking_number: 'UNKNOWN', @@ -78,7 +78,7 @@ const SpreadsheetTable = ({ useEffect(() => { setNewDewar((prev) => ({ ...prev, - contact_person_id: selectedShipment?.contact_person?.id, + contact_id: selectedShipment?.contact?.id, return_address_id: selectedShipment?.return_address?.id })); }, [selectedShipment]); @@ -259,8 +259,8 @@ const SpreadsheetTable = ({ }; const createOrUpdateDewarsFromSheet = async (data, contactPerson, returnAddress) => { - if (!contactPerson?.id || !returnAddress?.id) { - console.error('contact_person_id or return_address_id is missing'); + if (!contact?.id || !returnAddress?.id) { + console.error('contact_id or return_address_id is missing'); return null; } @@ -288,7 +288,7 @@ const SpreadsheetTable = ({ dewar = { ...initialNewDewarState, dewar_name: dewarName, - contact_person_id: contactPerson.id, + contact_id: contactPerson.id, return_address_id: returnAddress.id, pucks: [], }; @@ -498,7 +498,7 @@ const SpreadsheetTable = ({ await createOrUpdateDewarsFromSheet( raw_data, - selectedShipment?.contact_person, + selectedShipment?.contact, selectedShipment?.return_address ); diff --git a/frontend/src/hooks/useShipments.tsx b/frontend/src/hooks/useShipments.tsx index ee264d94cf7065ebe4dac3db81390708afde2826..4a0b6526569f4f4c3ada06d4bfb00819eef90401 100644 --- a/frontend/src/hooks/useShipments.tsx +++ b/frontend/src/hooks/useShipments.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from 'react'; -import { ShipmentsService, Shipment, ContactPerson } from '../../openapi'; +import { ShipmentsService, Shipment, Contact } from '../../openapi'; const useShipments = () => { const [shipments, setShipments] = useState<Shipment[]>([]); const [error, setError] = useState<string | null>(null); - const [defaultContactPerson, setDefaultContactPerson] = useState<ContactPerson | undefined>(); + const [defaultContact, setDefaultContact] = useState<Contact | undefined>(); const fetchAndSetShipments = async () => { try { @@ -16,10 +16,10 @@ const useShipments = () => { } }; - const fetchDefaultContactPerson = async () => { + const fetchDefaultContact = async () => { try { const contacts = await ShipmentsService.getShipmentContactPersonsShipmentsContactPersonsGet(); - setDefaultContactPerson(contacts[0]); + setDefaultContact(contacts[0]); } catch (error) { console.error('Failed to fetch contact persons:', error); setError('Failed to load contact persons. Please try again later.'); @@ -28,10 +28,10 @@ const useShipments = () => { useEffect(() => { fetchAndSetShipments(); - fetchDefaultContactPerson(); + fetchDefaultContact(); }, []); - return { shipments, error, defaultContactPerson, fetchAndSetShipments }; + return { shipments, error, defaultContact, fetchAndSetShipments }; }; export default useShipments; \ No newline at end of file diff --git a/frontend/src/pages/AddressManagerView.tsx b/frontend/src/pages/AddressManagerView.tsx index cc6c9cbdf6291d45d1f40e87350adec5ba16a699..e19ddd91740b549c3b4670902120a96386ad4380 100644 --- a/frontend/src/pages/AddressManagerView.tsx +++ b/frontend/src/pages/AddressManagerView.tsx @@ -78,7 +78,7 @@ const AddressManager: React.FC<AddressManagerProps> = ({ pgroups, activePgroup } React.useEffect(() => { const fetchAllData = async () => { try { - const response = await AddressesService.getAllAddressesAddressesAllGet(); + const response = await AddressesService.getAllAddressesProtectedAddressesAllGet(); // Preprocess: Add associated and unassociated pgroups const transformedAddresses = response.map((address) => { @@ -108,7 +108,7 @@ const AddressManager: React.FC<AddressManagerProps> = ({ pgroups, activePgroup } try { if (editAddressId !== null) { // Update address (mark old one obsolete, create a new one) - const updatedAddress = await AddressesService.updateReturnAddressAddressesAddressIdPut( + const updatedAddress = await AddressesService.updateReturnAddressProtectedAddressesAddressIdPut( editAddressId, newAddress as AddressUpdate ); @@ -120,7 +120,7 @@ const AddressManager: React.FC<AddressManagerProps> = ({ pgroups, activePgroup } setEditAddressId(null); } else { // Add new address - const response = await AddressesService.createReturnAddressAddressesPost(newAddress as AddressCreate); + const response = await AddressesService.createReturnAddressProtectedAddressesPost(newAddress as AddressCreate); setAddresses([...addresses, response]); } setNewAddress({ house_number:'', street: '', city: '', state: '', zipcode: '', country: '' }); @@ -134,7 +134,7 @@ const AddressManager: React.FC<AddressManagerProps> = ({ pgroups, activePgroup } const handleDeleteAddress = async (id: number) => { try { // Delete (inactivate) the address - await AddressesService.deleteReturnAddressAddressesAddressIdDelete(id); + await AddressesService.deleteReturnAddressProtectedAddressesAddressIdDelete(id); // Remove the obsolete address from the active list in the UI setAddresses(addresses.filter(address => address.id !== id && address.status === "active")); @@ -182,7 +182,7 @@ const AddressManager: React.FC<AddressManagerProps> = ({ pgroups, activePgroup } const updatedPgroups = [...address.associatedPgroups, pgroup]; // Add the pgroup // Update the backend - await AddressesService.updateReturnAddressAddressesAddressIdPut(addressId, { + await AddressesService.updateReturnAddressProtectedAddressesAddressIdPut(addressId, { ...address, pgroups: updatedPgroups.join(','), // Sync updated pgroups }); diff --git a/frontend/src/pages/ContactsManagerView.tsx b/frontend/src/pages/ContactsManagerView.tsx index 29ca986defc6270201b09a00d031a71de2ba4379..7ecd54e8828eb4e38c4617de70eed855b2e90112 100644 --- a/frontend/src/pages/ContactsManagerView.tsx +++ b/frontend/src/pages/ContactsManagerView.tsx @@ -1,158 +1,285 @@ import * as React from 'react'; import { - Container, Typography, List, ListItem, IconButton, TextField, Box, ListItemText, ListItemSecondaryAction, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button + Container, + Typography, + List, + ListItem, + IconButton, + TextField, + Box, + ListItemText, + ListItemSecondaryAction, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button, + Chip } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import SaveIcon from '@mui/icons-material/Save'; import AddIcon from '@mui/icons-material/Add'; -import { ContactsService } from '../../openapi'; -import type { ContactPerson, ContactPersonCreate, ContactPersonUpdate } from '../models/ContactPerson'; - -const ContactsManager: React.FC = () => { - const [contacts, setContacts] = React.useState<ContactPerson[]>([]); - const [newContact, setNewContact] = React.useState<Partial<ContactPerson>>({ - firstname: '', - lastname: '', - phone_number: '', - email: '', - }); - const [editContactId, setEditContactId] = React.useState<number | null>(null); - const [errorMessage, setErrorMessage] = React.useState<string | null>(null); - const [dialogOpen, setDialogOpen] = React.useState(false); - const [selectedContact, setSelectedContact] = React.useState<ContactPerson | null>(null); - - React.useEffect(() => { - const fetchContacts = async () => { - try { - const response = await ContactsService.getContactsContactsGet(); - setContacts(response); - } catch (error) { - console.error('Failed to fetch contacts', error); - setErrorMessage('Failed to load contacts. Please try again later.'); - } - }; - - fetchContacts(); - }, []); - - const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { - const { name, value } = event.target; - setNewContact({ ...newContact, [name]: value }); - }; +import {Contact, ContactCreate, ContactsService, ContactUpdate} from '../../openapi'; - const handleAddOrUpdateContact = async () => { - try { - if (editContactId !== null) { - // Update contact - await ContactsService.updateContactContactsContactIdPut(editContactId, newContact as ContactPersonUpdate); - setContacts(contacts.map(contact => contact.id === editContactId ? { ...contact, ...newContact } : contact)); - setEditContactId(null); - } else { - // Add new contact - const response = await ContactsService.createContactContactsPost(newContact as ContactPersonCreate); - setContacts([...contacts, response]); - } - setNewContact({ firstname: '', lastname: '', phone_number: '', email: '' }); - setErrorMessage(null); - } catch (error) { - console.error('Failed to add/update contact', error); - setErrorMessage('Failed to add/update contact. Please try again later.'); - } - }; - const handleDeleteContact = async (id: number) => { - try { - await ContactsService.deleteContactContactsContactIdDelete(id); - setContacts(contacts.filter(contact => contact.id !== id)); - } catch (error) { - console.error('Failed to delete contact', error); - setErrorMessage('Failed to delete contact. Please try again later.'); - } - }; +interface ContactsManagerProps { + pgroups: string[]; + activePgroup: string; +} - const handleEditContact = (contact: ContactPerson) => { - setEditContactId(contact.id); - setNewContact(contact); - }; +// Extend the generated Contact type +interface ContactWithPgroups extends Contact { + associatedPgroups: string[]; // Dynamically added pgroups +} - const openDialog = (contact: ContactPerson) => { - setSelectedContact(contact); - setDialogOpen(true); - }; +const ContactsManager: React.FC<ContactsManagerProps> = ({ pgroups, activePgroup }) => { + const [contacts, setContacts] = React.useState<ContactWithPgroups[]>([]); + const [newContact, setNewContact] = React.useState<Partial<Contact>>({ + firstname: '', + lastname: '', + phone_number: '', + email: '', + }); + const [editContactId, setEditContactId] = React.useState<number | null>(null); + const [errorMessage, setErrorMessage] = React.useState<string | null>(null); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [selectedContact, setSelectedContact] = React.useState<ContactWithPgroups | null>(null); - const closeDialog = () => { - setDialogOpen(false); - setSelectedContact(null); - }; + React.useEffect(() => { + const fetchContacts = async () => { + try { + const response = await ContactsService.getAllContactsProtectedContactsAllGet(); - const confirmDelete = async () => { - if (selectedContact) { - await handleDeleteContact(selectedContact.id); - closeDialog(); - } + // Preprocess: Add associated and unassociated pgroups + const transformedContacts = response.map((contact) => { + const contactPgroups = contact.pgroups?.split(',').map((p) => p.trim()) || []; + const associatedPgroups = pgroups.filter((pgroup) => contactPgroups.includes(pgroup)); + return { + ...contact, + associatedPgroups, // pgroups linked to the contact + }; + }); + + setContacts(transformedContacts); + } catch (error) { + console.error('Failed to fetch contacts', error); + setErrorMessage('Failed to load contacts. Please try again later.'); + } }; - return ( - <Container> - <Typography variant="h4" gutterBottom> - Contacts Management - </Typography> - <Box mb={3} display="flex" justifyContent="center" alignItems="center"> - <TextField label="First Name" name="firstname" value={newContact.firstname || ''} onChange={handleInputChange} /> - <TextField label="Last Name" name="lastname" value={newContact.lastname || ''} onChange={handleInputChange} /> - <TextField label="Phone Number" name="phone_number" value={newContact.phone_number || ''} onChange={handleInputChange} /> - <TextField label="Email" name="email" value={newContact.email || ''} onChange={handleInputChange} /> - <IconButton color="primary" onClick={handleAddOrUpdateContact}> - {editContactId !== null ? <SaveIcon /> : <AddIcon />} + fetchContacts(); + }, [pgroups]); + + const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const { name, value } = event.target; + setNewContact({ ...newContact, [name]: value }); + }; + + const handleAddOrUpdateContact = async () => { + try { + if (editContactId !== null) { + // Update contact + await ContactsService.updateContactProtectedContactsContactIdPut(editContactId, newContact as ContactUpdate); + setContacts( + contacts.map((contact) => + contact.id === editContactId ? { ...contact, ...newContact } : contact + ) + ); + setEditContactId(null); + } else { + // Add new contact + const response = await ContactsService.createContactProtectedContactsPost(newContact as ContactCreate); + setContacts([...contacts, response]); + } + setNewContact({ firstname: '', lastname: '', phone_number: '', email: '' }); + setErrorMessage(null); + } catch (error) { + console.error('Failed to add/update contact', error); + setErrorMessage('Failed to add/update contact. Please try again later.'); + } + }; + + const handleDeleteContact = async (id: number) => { + try { + await ContactsService.deleteContactProtectedContactsContactIdDelete(id); + setContacts(contacts.filter((contact) => contact.id !== id)); + } catch (error) { + console.error('Failed to delete contact', error); + setErrorMessage('Failed to delete contact. Please try again later.'); + } + }; + + const handleEditContact = (contact: Contact) => { + setEditContactId(contact.id); + setNewContact(contact); + }; + + const openDialog = (contact: ContactWithPgroups) => { + setSelectedContact(contact); + setDialogOpen(true); + }; + + const closeDialog = () => { + setDialogOpen(false); + setSelectedContact(null); + }; + + const confirmDelete = async () => { + if (selectedContact) { + await handleDeleteContact(selectedContact.id); + closeDialog(); + } + }; + + const togglePgroupAssociation = async (contactId: number, pgroup: string) => { + try { + const contact = contacts.find((c) => c.id === contactId); + if (!contact) return; + + const isAssociated = contact.associatedPgroups.includes(pgroup); + + // Only allow adding a pgroup + if (isAssociated) { + console.warn('Removing a pgroup is not allowed.'); + return; + } + + const updatedPgroups = [...contact.associatedPgroups, pgroup]; // Add the pgroup + + // Update the backend + await ContactsService.updateContactProtectedContactsContactIdPut(contactId, { + ...contact, + pgroups: updatedPgroups.join(','), // Sync updated pgroups + }); + + // Update contact in local state + setContacts((prevContacts) => + prevContacts.map((c) => + c.id === contactId ? { ...c, associatedPgroups: updatedPgroups } : c + ) + ); + } catch (error) { + console.error('Failed to add pgroup association', error); + setErrorMessage('Failed to add pgroup association. Please try again.'); + } + }; + + const renderPgroupChips = (contact: ContactWithPgroups) => { + return pgroups.map((pgroup) => { + const isAssociated = contact.associatedPgroups.includes(pgroup); + return ( + <Chip + key={pgroup} + label={pgroup} + onClick={ + !isAssociated + ? () => togglePgroupAssociation(contact.id, pgroup) + : undefined + } + sx={{ + backgroundColor: isAssociated ? '#19d238' : '#b0b0b0', + color: 'white', + borderRadius: '8px', + fontWeight: 'bold', + 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 ( + <Container> + <Typography variant="h4" gutterBottom> + Contacts Management + </Typography> + <Box mb={3} display="flex" justifyContent="center" alignItems="center"> + <TextField + label="First Name" + name="firstname" + value={newContact.firstname || ''} + onChange={handleInputChange} + /> + <TextField + label="Last Name" + name="lastname" + value={newContact.lastname || ''} + onChange={handleInputChange} + /> + <TextField + label="Phone Number" + name="phone_number" + value={newContact.phone_number || ''} + onChange={handleInputChange} + /> + <TextField + label="Email" + name="email" + value={newContact.email || ''} + onChange={handleInputChange} + /> + <IconButton color="primary" onClick={handleAddOrUpdateContact}> + {editContactId !== null ? <SaveIcon /> : <AddIcon />} + </IconButton> + </Box> + {errorMessage && <Typography color="error">{errorMessage}</Typography>} + <List> + {contacts.length > 0 ? ( + contacts.map((contact) => ( + <ListItem key={contact.id} button> + <ListItemText + primary={`${contact.firstname} ${contact.lastname}`} + secondary={ + <Box display="flex" flexWrap="wrap"> + {renderPgroupChips(contact)} + </Box> + } + /> + <ListItemSecondaryAction> + <IconButton edge="end" color="primary" onClick={() => handleEditContact(contact)}> + <EditIcon /> + </IconButton> + <IconButton edge="end" color="secondary" onClick={() => openDialog(contact)}> + <DeleteIcon /> </IconButton> - </Box> - {errorMessage && <Typography color="error">{errorMessage}</Typography>} - <List> - {contacts.length > 0 ? ( - contacts.map((contact) => ( - <ListItem key={contact.id} button> - <ListItemText - primary={`${contact.firstname} ${contact.lastname}`} - secondary={`${contact.phone_number} - ${contact.email}`} - /> - <ListItemSecondaryAction> - <IconButton edge="end" color="primary" onClick={() => handleEditContact(contact)}> - <EditIcon /> - </IconButton> - <IconButton edge="end" color="secondary" onClick={() => openDialog(contact)}> - <DeleteIcon /> - </IconButton> - </ListItemSecondaryAction> - </ListItem> - )) - ) : ( - <Typography>No contacts found</Typography> - )} - </List> - <Dialog - open={dialogOpen} - onClose={closeDialog} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - <DialogTitle id="alert-dialog-title">{"Confirm Delete"}</DialogTitle> - <DialogContent> - <DialogContentText id="alert-dialog-description"> - Are you sure you want to delete this contact? - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={closeDialog} color="primary"> - Cancel - </Button> - <Button onClick={confirmDelete} color="secondary" autoFocus> - Delete - </Button> - </DialogActions> - </Dialog> - </Container> - ); + </ListItemSecondaryAction> + </ListItem> + )) + ) : ( + <Typography>No contacts found</Typography> + )} + </List> + <Dialog + open={dialogOpen} + onClose={closeDialog} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + <DialogTitle id="alert-dialog-title">{"Confirm Delete"}</DialogTitle> + <DialogContent> + <DialogContentText id="alert-dialog-description"> + Are you sure you want to delete this contact? + </DialogContentText> + </DialogContent> + <DialogActions> + <Button onClick={closeDialog} color="primary"> + Cancel + </Button> + <Button onClick={confirmDelete} color="secondary" autoFocus> + Delete + </Button> + </DialogActions> + </Dialog> + </Container> + ); }; export default ContactsManager; \ No newline at end of file diff --git a/frontend/src/pages/ShipmentView.tsx b/frontend/src/pages/ShipmentView.tsx index 02127b3c4dd213878fbcf55e7396a331adaa172a..1cc2c9ac08b2760d3869f51002f29173d88f6436 100644 --- a/frontend/src/pages/ShipmentView.tsx +++ b/frontend/src/pages/ShipmentView.tsx @@ -11,7 +11,7 @@ type ShipmentViewProps = { }; const ShipmentView: React.FC<ShipmentViewProps> = ( { activePgroup }) => { - const { shipments, error, defaultContactPerson, fetchAndSetShipments } = useShipments(); + const { shipments, error, defaultContact, fetchAndSetShipments } = useShipments(); const [selectedShipment, setSelectedShipment] = useState<Shipment | null>(null); const [selectedDewar, setSelectedDewar] = useState<Dewar | null>(null); const [isCreatingShipment, setIsCreatingShipment] = useState(false); @@ -76,7 +76,7 @@ const ShipmentView: React.FC<ShipmentViewProps> = ( { activePgroup }) => { setSelectedDewar={setSelectedDewar} setSelectedShipment={setSelectedShipment} refreshShipments={fetchAndSetShipments} - defaultContactPerson={defaultContactPerson} + defaultContact={defaultContact} /> ); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e25b3106575c9dc430c6ce465d4ec5a80988a4d2..dc67538a85062cc2324eaa382572139a44a760ae 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,4 +1,4 @@ -export interface ContactPerson { +export interface Contact { id: string; lastname: string; firstname: string; @@ -29,7 +29,7 @@ export interface Dewar { number_of_pucks: number; number_of_samples: number; return_address: ReturnAddress[]; - contact_person: ContactPerson[]; + contact_: Contact[]; status: string; ready_date?: string; // Make sure this is included shipping_date?: string; // Make sure this is included @@ -45,7 +45,7 @@ export interface Shipment { shipment_date: string; number_of_dewars: number; shipment_status: string; - contact_person: ContactPerson[] | null; // Change to an array to accommodate multiple contacts + contact_: Contact[] | null; // Change to an array to accommodate multiple contacts proposal_number?: string; return_address: Address[]; // Change to an array of Address comments?: string;