diff --git a/api/main.py b/api/main.py index 99ecf150..fd0a70a9 100644 --- a/api/main.py +++ b/api/main.py @@ -25,13 +25,14 @@ from api.routes.activities import activity_router from api.routes.admin import admin_router -from api.routes.chlorides import chlorides_router +from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router from api.routes.maintenance import maintenance_router -from api.routes.meters import meter_router +from api.routes.meters import authenticated_meter_router, public_meter_router from api.routes.OSE import ose_router from api.routes.parts import part_router -from api.routes.well_measurements import well_measurement_router -from api.routes.wells import well_router +from api.routes.settings import settings_router +from api.routes.well_measurements import authenticated_well_measurement_router, public_well_measurement_router +from api.routes.wells import authenticated_well_router, public_well_router from api.security import ( authenticate_user, @@ -130,14 +131,19 @@ def login_for_access_token( authenticated_router.include_router(activity_router) authenticated_router.include_router(admin_router) -authenticated_router.include_router(chlorides_router) +authenticated_router.include_router(authenticated_chlorides_router) authenticated_router.include_router(maintenance_router) -authenticated_router.include_router(meter_router) +authenticated_router.include_router(authenticated_meter_router) authenticated_router.include_router(part_router) -authenticated_router.include_router(well_measurement_router) -authenticated_router.include_router(well_router) +authenticated_router.include_router(authenticated_well_measurement_router) +authenticated_router.include_router(authenticated_well_router) +authenticated_router.include_router(settings_router) add_pagination(app) app.include_router(ose_router) +app.include_router(public_meter_router) +app.include_router(public_well_router) +app.include_router(public_chlorides_router) +app.include_router(public_well_measurement_router) app.include_router(authenticated_router) diff --git a/api/models/main_models.py b/api/models/main_models.py index fcd56376..d174d471 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -8,8 +8,8 @@ func, Boolean, Table, + Numeric, ) - from sqlalchemy.orm import ( relationship, DeclarativeBase, @@ -17,7 +17,6 @@ Mapped, deferred, ) -from geoalchemy2 import Geometry from geoalchemy2.shape import to_shape from typing import Optional, List @@ -149,6 +148,7 @@ class Meters(Base): contact_name: Mapped[Optional[str]] = mapped_column(String) contact_phone: Mapped[Optional[str]] = mapped_column(String) notes: Mapped[Optional[str]] = mapped_column(String) + price: Mapped[Optional[float]] = mapped_column(Numeric(10, 2)) meter_type_id: Mapped[int] = mapped_column( Integer, ForeignKey("MeterTypeLU.id"), nullable=False @@ -174,6 +174,7 @@ class Meters(Base): location: Mapped["Locations"] = relationship() + class MeterTypeLU(Base): """ Meter types @@ -238,6 +239,25 @@ class MeterActivities(Base): notes: Mapped[List["NoteTypeLU"]] = relationship("NoteTypeLU", secondary=Notes) work_order: Mapped["workOrders"] = relationship() well: Mapped["Wells"] = relationship("Wells", primaryjoin='MeterActivities.location_id == Wells.location_id', foreign_keys='MeterActivities.location_id', viewonly=True) + photos: Mapped[List["MeterActivityPhotos"]] = relationship("MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete") + + +class MeterActivityPhotos(Base): + __tablename__ = "MeterActivityPhotos" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True, autoincrement=True) + meter_activity_id: Mapped[int] = mapped_column( + Integer, ForeignKey("MeterActivities.id", ondelete="CASCADE"), nullable=False + ) + file_name: Mapped[str] = mapped_column(String, nullable=False) + gcs_path: Mapped[str] = mapped_column(String, nullable=False) + uploaded_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + meter_activity: Mapped["MeterActivities"] = relationship( + "MeterActivities", back_populates="photos" + ) class ActivityTypeLU(Base): @@ -421,6 +441,9 @@ class Users(Base): ) user_role: Mapped["UserRoles"] = relationship("UserRoles") + display_name: Mapped[str] = mapped_column(String, nullable=True) + redirect_page: Mapped[str] = mapped_column(String, nullable=True, default="/") + avatar_img: Mapped[str] = mapped_column(String, nullable=True) # Association table that links roles and their associated scopes diff --git a/api/routes/activities.py b/api/routes/activities.py index 068063d8..629a3867 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -1,11 +1,10 @@ -from fastapi import Depends, APIRouter, Query +from fastapi import Depends, APIRouter, Query, File, UploadFile, Form from fastapi.exceptions import HTTPException from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError from sqlalchemy import select, text from datetime import datetime from typing import List - from api import security from api.schemas import meter_schemas from api.models.main_models import ( @@ -15,6 +14,7 @@ ActivityTypeLU, Units, MeterActivities, + MeterActivityPhotos, MeterObservations, ServiceTypeLU, NoteTypeLU, @@ -28,25 +28,50 @@ from api.session import get_db from api.security import get_current_user from api.enums import ScopedUser, WorkOrderStatus +from pathlib import Path +from google.cloud import storage + +import uuid +import json +import os activity_router = APIRouter() +BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") +PHOTO_PREFIX = os.getenv("GCP_PHOTO_PREFIX", "") + +MAX_PHOTOS_PER_REQUEST = 2 +MAX_PHOTOS_PER_METER = 6 -# Process a submitted activity -# Returns 422 when one or more required fields of ActivityForm are not present -# Returns the new MeterActivity on success @activity_router.post( "/activities", response_model=meter_schemas.MeterActivity, dependencies=[Depends(ScopedUser.ActivityWrite)], tags=["Activities"], ) -def post_activity( - activity_form: meter_schemas.ActivityForm, db: Session = Depends(get_db), user: Users = Depends(get_current_user) +async def post_activity( + activity: str = Form(...), # JSON string from FormData + photos: list[UploadFile] = File(None), + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), ): """ - Handles submission of an activity. + Handles submission of an activity (with optional file uploads). """ + + if photos: + if len(photos) > MAX_PHOTOS_PER_REQUEST: + raise HTTPException( + status_code=400, + detail=f"Too many photos uploaded. " + f"Max {MAX_PHOTOS_PER_REQUEST} allowed per request, got {len(photos)}.", + ) + + try: + activity_form = meter_schemas.ActivityForm.parse_obj(json.loads(activity)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid activity payload: {e}") + # Set some variables that will be used to determine how the meter is updated update_meter_state = True user_level = user.user_role.name @@ -122,6 +147,7 @@ def post_activity( try: db.add(meter_activity) db.commit() + db.refresh(meter_activity) # make sure meter_activity.id is available except IntegrityError as _e: raise HTTPException( status_code=409, detail="Activity overlaps with existing activity." @@ -235,6 +261,63 @@ def post_activity( db.commit() + + # ---- Handle photo file uploads ---- + if photos: + print(f"Received {len(photos)} photos") + print(f"Uploading to bucket={BUCKET_NAME}, prefix={PHOTO_PREFIX}") + client = storage.Client() + bucket = client.bucket(BUCKET_NAME) + + for file in photos: + ext = Path(file.filename).suffix or ".jpg" + unique_name = f"{uuid.uuid4()}{ext}" + blob_path = f"{PHOTO_PREFIX}/{meter_activity.id}/{unique_name}" + blob = bucket.blob(blob_path) + + # Upload file content directly + try: + contents = await file.read() + print(f"Uploading {file.filename}, size={len(contents)} bytes") + + blob.upload_from_string(contents, content_type=file.content_type) + print(f"Uploaded to gs://{BUCKET_NAME}/{blob_path}") + except Exception as e: + print(f"ERROR uploading {file.filename}: {e}") + raise + + photo = MeterActivityPhotos( + meter_activity_id=meter_activity.id, + file_name=file.filename, + gcs_path=blob_path, + ) + db.add(photo) + + db.commit() + print(f"Saved {len(photos)} photos for activity {meter_activity.id}") + db.refresh(meter_activity) + + # ---- Enforce per-meter retention ---- + all_photos = ( + db.query(MeterActivityPhotos) + .join(MeterActivities) + .filter(MeterActivities.meter_id == meter_activity.meter_id) + .order_by(MeterActivityPhotos.uploaded_at.desc()) + .all() + ) + + if len(all_photos) > MAX_PHOTOS_PER_METER: + # keep newest MAX_PHOTOS_PER_METER, delete the rest + to_delete = all_photos[MAX_PHOTOS_PER_METER:] + for old_photo in to_delete: + try: + bucket.blob(old_photo.gcs_path).delete() + except Exception as e: + print(f"Warning: failed to delete {old_photo.gcs_path} from GCS: {e}") + db.delete(old_photo) + + db.commit() + return meter_activity @activity_router.patch( @@ -309,6 +392,24 @@ def delete_activity(activity_id: int, db: Session = Depends(get_db)): # Get the activity activity = db.scalars(select(MeterActivities).where(MeterActivities.id == activity_id)).first() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found.") + + photos = db.scalars( + select(MeterActivityPhotos).where(MeterActivityPhotos.meter_activity_id == activity_id) + ).all() + + storage_client = storage.Client() + bucket = storage_client.bucket(BUCKET_NAME) + + for photo in photos: + try: + blob = bucket.blob(photo.gcs_path) + blob.delete() + print(f"Deleted GCS object: {photo.gcs_path}") + except Exception as e: + print(f"Failed to delete {photo.gcs_path} from bucket: {e}") + # Delete any notes associated with the activity sql = text('DELETE FROM "Notes" WHERE meter_activity_id = :activity_id') db.execute(sql, {'activity_id': activity_id}) diff --git a/api/routes/admin.py b/api/routes/admin.py index 432d4946..b4eb7d0e 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -86,6 +86,7 @@ def create_user(user: security_schemas.NewUser, db: Session = Depends(get_db)): username=user.username, email=user.email, full_name=user.full_name, + display_name=user.display_name, user_role_id=user.user_role_id, disabled=user.disabled, hashed_password=pwd_context.hash(user.password), diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index add44419..455b298f 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -1,6 +1,7 @@ from typing import Optional, List from datetime import datetime import calendar +import statistics from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO @@ -27,11 +28,11 @@ autoescape=select_autoescape(["html", "xml"]) ) -chlorides_router = APIRouter() +authenticated_chlorides_router = APIRouter() +public_chlorides_router = APIRouter() -@chlorides_router.get( +@public_chlorides_router.get( "/chlorides", - dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.WellMeasurementDTO], tags=["Chlorides"], ) @@ -57,9 +58,8 @@ def read_chlorides( ).all() -@chlorides_router.get( +@public_chlorides_router.get( "/chloride_groups", - dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.ChlorideGroupResponse], tags=["Chlorides"], ) @@ -95,20 +95,22 @@ def get_chloride_groups( for group_id, names in groups.items() ] -class MinMaxAvg(BaseModel): +class MinMaxAvgMedCount(BaseModel): min: Optional[float] = None max: Optional[float] = None avg: Optional[float] = None + median: Optional[float] = None + count: int = 0 class ChlorideReportNums(BaseModel): - north: MinMaxAvg - south: MinMaxAvg - east: MinMaxAvg - west: MinMaxAvg + north: MinMaxAvgMedCount + south: MinMaxAvgMedCount + east: MinMaxAvgMedCount + west: MinMaxAvgMedCount -@chlorides_router.get( +@authenticated_chlorides_router.get( "/chlorides/report", dependencies=[Depends(ScopedUser.Read)], response_model=ChlorideReportNums, @@ -203,7 +205,7 @@ def get_chlorides_report( ) -@chlorides_router.get( +@authenticated_chlorides_router.get( "/chlorides/report/pdf", dependencies=[Depends(ScopedUser.Read)], tags=["Chlorides"], @@ -249,7 +251,7 @@ def download_chlorides_report_pdf( }, ) -@chlorides_router.post( +@authenticated_chlorides_router.post( "/chlorides", dependencies=[Depends(ScopedUser.WellMeasurementWrite)], response_model=well_schemas.ChlorideMeasurement, @@ -274,7 +276,7 @@ def add_chloride_measurement( return well_measurement -@chlorides_router.patch( +@authenticated_chlorides_router.patch( "/chlorides", dependencies=[Depends(ScopedUser.WellMeasurementWrite)], response_model=well_schemas.WellMeasurement, @@ -300,7 +302,7 @@ def patch_chloride_measurement( return well_measurement -@chlorides_router.delete( +@authenticated_chlorides_router.delete( "/chlorides", dependencies=[Depends(ScopedUser.Admin)], tags=["Chlorides"], @@ -337,13 +339,17 @@ def _month_end(dt: datetime) -> datetime: last_day = calendar.monthrange(dt.year, dt.month)[1] return dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=999999) -def _stats(values: List[float]) -> MinMaxAvg: - if not values: - return MinMaxAvg() - return MinMaxAvg( - min=min(values), - max=max(values), - avg=(sum(values) / len(values)) +def _stats(values: List[Optional[float]]) -> MinMaxAvgMedCount: + clean = [v for v in values if v is not None] + if not clean: + return MinMaxAvgMedCount() + + return MinMaxAvgMedCount( + min=min(clean), + max=max(clean), + avg=sum(clean) / len(clean), + median=statistics.median(clean), + count=len(clean), ) # Approx NM bounding box (degrees) diff --git a/api/routes/meters.py b/api/routes/meters.py index c94357ea..cc0b8b75 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -6,8 +6,10 @@ from sqlalchemy.exc import IntegrityError from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination import LimitOffsetPage +from fastapi.responses import StreamingResponse from enum import Enum - +from io import BytesIO +from jose import jwt, JWTError, ExpiredSignatureError from api.schemas import meter_schemas from api.schemas import well_schemas from api.models.main_models import ( @@ -25,12 +27,23 @@ from api.route_util import _patch, _get from api.session import get_db from api.enums import ScopedUser, MeterSortByField, MeterStatus, SortDirection +from google.cloud import storage + +import time +import mimetypes +import os -meter_router = APIRouter() +authenticated_meter_router = APIRouter() +public_meter_router = APIRouter() +# Generate random secret at startup +PHOTO_JWT_SECRET = os.getenv("PHOTO_JWT_SECRET", "super-secret") +PHOTO_JWT_ALGORITHM = "HS256" +PHOTO_JWT_EXPIRE_SECONDS = 600 # 10 minutes +BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") # Get paginated, sorted list of meters, filtered by a search string if applicable -@meter_router.get( +@authenticated_meter_router.get( "/meters", dependencies=[Depends(ScopedUser.Read)], response_model=LimitOffsetPage[meter_schemas.MeterListDTO], @@ -102,7 +115,7 @@ def sort_by_field_to_schema_field(name: MeterSortByField): return paginate(db, query_statement) -@meter_router.post( +@authenticated_meter_router.post( "/meters", response_model=meter_schemas.Meter, dependencies=[Depends(ScopedUser.Admin)], @@ -129,6 +142,7 @@ def create_meter( contact_name=new_meter.contact_name, contact_phone=new_meter.contact_phone, meter_type_id=new_meter.meter_type.id, + price=new_meter.price, status_id=warehouse_status_id, location_id=warehouse_location_id, meter_owner="PVACD", @@ -151,7 +165,7 @@ def create_meter( try: db.add(new_meter_model) db.commit() - except IntegrityError as e: + except IntegrityError: raise HTTPException(status_code=409, detail="Meter already exists") db.refresh(new_meter_model) @@ -162,7 +176,7 @@ def create_meter( # Get search for meters similar to /meters but no pagination and only for installed meters # Returns all installed meters with a location when search is None # Also returns year of last PM for color coding on the map -@meter_router.get( +@authenticated_meter_router.get( "/meters_locations", dependencies=[Depends(ScopedUser.Read)], response_model=List[meter_schemas.MeterMapDTO], @@ -261,7 +275,7 @@ def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = # Get single, fully qualified meter # Can use either meter_id or serial_number -@meter_router.get( +@authenticated_meter_router.get( "/meter", tags=["Meters"], ) @@ -289,7 +303,7 @@ def get_meter( return db.scalars(query).first() -@meter_router.get( +@authenticated_meter_router.get( "/meter_types", response_model=List[meter_schemas.MeterTypeLU], dependencies=[Depends(ScopedUser.Read)], @@ -300,7 +314,7 @@ def get_meter_types(db: Session = Depends(get_db)): # A route to return register types from meter_register table -@meter_router.get( +@authenticated_meter_router.get( "/meter_registers", response_model=List[meter_schemas.MeterRegister], dependencies=[Depends(ScopedUser.Read)], @@ -317,7 +331,7 @@ def get_meter_registers(db: Session = Depends(get_db)): # A route to return status types from the MeterStatusLU table -@meter_router.get( +@authenticated_meter_router.get( "/meter_status_types", response_model=List[meter_schemas.MeterStatusLU], dependencies=[Depends(ScopedUser.Read)], @@ -327,7 +341,7 @@ def get_meter_status(db: Session = Depends(get_db)): return db.scalars(select(MeterStatusLU)).all() -@meter_router.patch( +@authenticated_meter_router.patch( "/meter_types", response_model=meter_schemas.MeterTypeLU, dependencies=[Depends(ScopedUser.Admin)], @@ -345,7 +359,7 @@ def update_meter_type( return meter_type -@meter_router.post( +@authenticated_meter_router.post( "/meter_types", response_model=meter_schemas.MeterTypeLU, dependencies=[Depends(ScopedUser.Admin)], @@ -370,7 +384,7 @@ def create_meter_type( return new_type_model -@meter_router.get( +@authenticated_meter_router.get( "/land_owners", dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.LandOwner], @@ -382,7 +396,7 @@ def get_land_owners( return db.scalars(select(LandOwners)).all() -@meter_router.patch( +@authenticated_meter_router.patch( "/meter", dependencies=[Depends(ScopedUser.Admin)], response_model=meter_schemas.Meter, @@ -398,21 +412,15 @@ def patch_meter( """ meter_db = _get(db, Meters, updated_meter.id) - # Update the meter meter_db.serial_number = updated_meter.serial_number meter_db.contact_name = updated_meter.contact_name meter_db.contact_phone = updated_meter.contact_phone meter_db.notes = updated_meter.notes + meter_db.price = updated_meter.price meter_db.meter_type_id = updated_meter.meter_type.id meter_db.water_users = updated_meter.water_users meter_db.meter_owner = updated_meter.meter_owner meter_db.register_id = updated_meter.meter_register.id - # for k, v in updated_meter.model_dump(exclude_unset=True).items(): - # try: - # setattr(meter_db, k, v) - # except AttributeError as e: - # print(e) - # continue # If there is a well set, update status, well and location if updated_meter.well: @@ -434,7 +442,7 @@ def patch_meter( try: db.add(meter_db) db.commit() - except IntegrityError as e: + except IntegrityError: raise HTTPException(status_code=409, detail="Meter already exists") return db.scalars( @@ -448,9 +456,38 @@ def patch_meter( ).first() -# Build a list of a meter's history (activities and observations) -# There's no real defined structure/schema to this on the front or backend -@meter_router.get( +@public_meter_router.get("/photos/{path:path}") +def get_photo(path: str, token: str = Query(...)): + # raises 401 if invalid + blob_path = verify_photo_token(token) + + # extra guard: token "sub" must match requested path + if blob_path != path: + raise HTTPException(status_code=403, detail="Token path mismatch") + + client = storage.Client() + bucket = client.bucket(BUCKET_NAME) + blob = bucket.blob(path) + + if not blob.exists(): + raise HTTPException(status_code=404, detail="Photo not found") + + content_type, _ = mimetypes.guess_type(path) + content_type = content_type or "application/octet-stream" + + data = blob.download_as_bytes() + + return StreamingResponse( + BytesIO(data), + media_type=content_type, + headers={ + "Cache-Control": f"public, max-age={PHOTO_JWT_EXPIRE_SECONDS}", + "ETag": blob.etag or "", + }, + ) + + +@authenticated_meter_router.get( "/meter_history", dependencies=[Depends(ScopedUser.Read)], tags=["Meters"] ) def get_meter_history(meter_id: int, db: Session = Depends(get_db)): @@ -473,7 +510,7 @@ class HistoryType(Enum): joinedload(MeterActivities.activity_type), joinedload(MeterActivities.parts_used).joinedload(Parts.part_type), joinedload(MeterActivities.notes), - joinedload(MeterActivities.services_performed) + joinedload(MeterActivities.services_performed), ) .filter(MeterActivities.meter_id == meter_id) ) @@ -502,6 +539,16 @@ class HistoryType(Enum): #Find if there is a well associated with the location activity_well = db.scalars(select(Wells).where(Wells.location_id == activity.location_id)).first() + photos = [ + { + "id": photo.id, + "file_name": photo.file_name, + "url": f"/photos/{photo.gcs_path}?token={create_photo_token(photo.gcs_path)}", + "uploaded_at": photo.uploaded_at, + } + for photo in activity.photos + ] + formattedHistoryItems.append( { "id": itemID, @@ -511,6 +558,7 @@ class HistoryType(Enum): "activity_type": activity.activity_type_id, "date": activity.timestamp_start, "history_item": activity, + "photos": photos, } ) itemID += 1 @@ -538,3 +586,25 @@ class HistoryType(Enum): formattedHistoryItems.sort(key=lambda x: x["date"], reverse=True) return formattedHistoryItems + + +def create_photo_token(blob_path: str) -> str: + expire = int(time.time()) + PHOTO_JWT_EXPIRE_SECONDS + payload = {"sub": blob_path, "exp": expire} + token = jwt.encode(payload, PHOTO_JWT_SECRET, algorithm=PHOTO_JWT_ALGORITHM) + return token + + +def verify_photo_token(token: str) -> str: + try: + payload = jwt.decode( + token, + PHOTO_JWT_SECRET, + algorithms=[PHOTO_JWT_ALGORITHM] + ) + return payload["sub"] + except ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except JWTError as e: + print("Token verification failed:", str(e)) + raise HTTPException(status_code=401, detail="Invalid token") diff --git a/api/routes/old/alerts.py b/api/routes/old/alerts.py deleted file mode 100644 index cdd41302..00000000 --- a/api/routes/old/alerts.py +++ /dev/null @@ -1,66 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from typing import List - -from fastapi import APIRouter, Depends, Security, HTTPException -from sqlalchemy.orm import Session - -from api.schemas import meter_schemas -from api.models import Alert -from api.route_util import _add, _patch -from api.security import get_current_user, scoped_user -from api.security_schemas import User -from api.session import get_db - -alert_router = APIRouter() -write_user = scoped_user(["read", "alerts:write"]) - - -@alert_router.get("/alerts", response_model=List[meter_schemas.Alert], tags=["alerts"]) -async def read_alerts(db: Session = Depends(get_db)): - return db.query(Alert).all() - - -@alert_router.get( - "/alerts/{alert_id}", response_model=meter_schemas.Alert, tags=["alerts"] -) -async def read_alerts(alert_id: int, db: Session = Depends(get_db)): - return db.query(Alert).filter(Alert.id == alert_id).first() - - -@alert_router.post( - "/alerts", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Alert, - tags=["alerts"], -) -async def add_alerts(alert: meter_schemas.AlertCreate, db: Session = Depends(get_db)): - return _add(db, Alert, alert) - - -@alert_router.patch( - "/alerts/{alert_id}", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Alert, - tags=["alerts"], -) -async def patch_alerts( - alert_id: int, obj: meter_schemas.AlertPatch, db: Session = Depends(get_db) -): - return _patch(db, Alert, alert_id, obj) - - -# ============= EOF ============================================= diff --git a/api/routes/old/contacts.py b/api/routes/old/contacts.py deleted file mode 100644 index 84cb86b1..00000000 --- a/api/routes/old/contacts.py +++ /dev/null @@ -1,40 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from typing import List - -from fastapi import APIRouter, Depends -from sqlalchemy import func -from sqlalchemy.orm import Session - -from api.schemas import meter_schemas -from api.models import Well, WellConstruction, Contacts -from api.route_util import _patch, _add -from api.security import scoped_user -from api.session import get_db - -contacts_router = APIRouter() - -write_user = scoped_user(["read", "wells:write"]) - - -@contacts_router.get( - "/contacts", response_model=List[meter_schemas.Owner], tags=["contacts"] -) -def read_contacts(db: Session = Depends(get_db)): - return db.query(Contacts).all() - - -# ============= EOF ============================================= diff --git a/api/routes/old/parts.py b/api/routes/old/parts.py deleted file mode 100644 index 54a7a629..00000000 --- a/api/routes/old/parts.py +++ /dev/null @@ -1,149 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from typing import List - -from fastapi import Depends, APIRouter -from sqlalchemy.orm import Session - -from api.schemas import meter_schemas -from api.models import Meters, Part, Well -from api.route_util import _add, _patch, _delete -from api.security import scoped_user -from api.session import get_db - -part_router = APIRouter() - -write_user = scoped_user(["read", "parts:write"]) - - -@part_router.post( - "/parts", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Part, - tags=["parts"], -) -async def add_part(part: meter_schemas.PartCreate, db: Session = Depends(get_db)): - db_item = Part(**part.dict()) - db.add(db_item) - db.commit() - db.refresh(db_item) - return db_item - - -@part_router.delete( - "/parts/{part_id}", dependencies=[Depends(write_user)], tags=["parts"] -) -async def delete_part(part_id: int, db: Session = Depends(get_db)): - return _delete(db, Part, part_id) - - -@part_router.patch( - "/parts/{part_id}", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Part, - tags=["parts"], -) -async def patch_parts( - part_id: int, obj: meter_schemas.Part, db: Session = Depends(get_db) -): - return _patch(db, Part, part_id, obj) - - -@part_router.get("/parts", response_model=List[meter_schemas.Part], tags=["parts"]) -async def read_parts( - # location: str = None, - # well_id: int = None, - # meter_id: int = None, - db: Session = Depends(get_db), -): - q = db.query(Part) - return q.all() - - -# ============================================================ -# @part_router.post( -# "/part_classes", -# dependencies=[Depends(write_user)], -# response_model=schemas.PartClass, -# tags=["parts"], -# ) -# async def add_part_class(part_class: schemas.PartClassCreate, db: Session = Depends(get_db)): -# db_item = PartClass(**part_class.dict()) -# db.add(db_item) -# db.commit() -# db.refresh(db_item) -# return db_item -# -# -# @part_router.delete( -# "/part_classes/{part_class_id}", dependencies=[Depends(write_user)], tags=["parts"] -# ) -# async def delete_part_class(part_class_id: int, db: Session = Depends(get_db)): -# return _delete(db, PartClass, part_class_id) -# -# -# @part_router.patch( -# "/part_classes/{part_class_id}", -# dependencies=[Depends(write_user)], -# response_model=schemas.PartClass, -# tags=["parts"], -# ) -# async def patch_part_classes( -# part_class_id: int, obj: schemas.PartClass, db: Session = Depends(get_db) -# ): -# return _patch(db, PartClass, part_class_id, obj) -# -# -# @part_router.get("/part_classes", response_model=List[schemas.PartClass], tags=["parts"]) -# async def read_part_classes( -# # location: str = None, -# # well_id: int = None, -# # meter_id: int = None, -# db: Session = Depends(get_db), -# ): -# q = db.query(PartClass) -# return q.all() - -# q = part_query(db, location, well_id, meter_id) -# return q.all() - -# -# def parse_location(location_str): -# return location_str.split(".") -# -# -# def part_query(db, location, well_id, meter_id): -# q = db.query(Part) -# q = q.join(Well) -# -# if meter_id is not None: -# q = q.join(MeterHistory) -# q = q.filter(MeterHistory.meter_id == meter_id) -# elif well_id is not None: -# q = q.filter(Well.id == well_id) -# elif location is not None: -# t, r, s, qu, hq = parse_location(location) -# q = ( -# q.filter(Well.township == t) -# .filter(Well.range == r) -# .filter(Well.section == s) -# .filter(Well.quarter == qu) -# .filter(Well.half_quarter == hq) -# ) -# return q - - -# ============= EOF ============================================= diff --git a/api/routes/old/repairs.py b/api/routes/old/repairs.py deleted file mode 100644 index 6f623434..00000000 --- a/api/routes/old/repairs.py +++ /dev/null @@ -1,117 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from http.client import HTTPException -from typing import List - -from fastapi import Depends, APIRouter -from sqlalchemy.orm import Session - -from api.schemas import meter_schemas -from api.models import Meters, Repair, Well, QC -from api.route_util import _add, _patch, _delete -from api.security import scoped_user -from api.session import get_db - -repair_router = APIRouter() - -write_user = scoped_user(["read", "repairs:write"]) - - -@repair_router.post( - "/repairs", - dependencies=[Depends(write_user)], - response_model=meter_schemas.RepairCreate, - tags=["repairs"], -) -async def add_repair(repair: meter_schemas.RepairCreate, db: Session = Depends(get_db)): - # save empty qc reference - qc = QC() - db.add(qc) - - db_item = Repair(**repair.dict()) - db_item.qc = qc - db.add(db_item) - db.commit() - db.refresh(db_item) - return db_item - - -@repair_router.delete( - "/repairs/{repair_id}", dependencies=[Depends(write_user)], tags=["repairs"] -) -async def delete_repair(repair_id: int, db: Session = Depends(get_db)): - return _delete(db, Repair, repair_id) - - -@repair_router.patch( - "/repairs/{repair_id}", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Repair, - tags=["repairs"], -) -async def patch_repairs( - repair_id: int, obj: meter_schemas.Repair, db: Session = Depends(get_db) -): - return _patch(db, Repair, repair_id, obj) - - -@repair_router.get( - "/repairs", response_model=List[meter_schemas.Repair], tags=["repairs"] -) -async def read_repairs( - location: str = None, - well_id: int = None, - meter_id: int = None, - db: Session = Depends(get_db), -): - try: - user = write_user() - public_release = True - except HTTPException: - public_release = False - - q = repair_query(db, location, well_id, meter_id, public_release) - - return q.all() - - -def parse_location(location_str): - return location_str.split(".") - - -# def repair_query(db, location, well_id, meter_id, public_release): -# q = db.query(Repair) -# q = q.join(Well) - -# if meter_id is not None: -# q = q.join(MeterHistory) -# q = q.filter(MeterHistory.meter_id == meter_id) -# elif well_id is not None: -# q = q.filter(Well.id == well_id) -# elif location is not None: -# t, r, s, qu, hq = parse_location(location) -# q = ( -# q.filter(Well.township == t) -# .filter(Well.range == r) -# .filter(Well.section == s) -# .filter(Well.quarter == qu) -# .filter(Well.half_quarter == hq) -# ) -# q = q.filter(Repair.public_release == public_release) -# return q - - -# ============= EOF ============================================= diff --git a/api/routes/old/reports.py b/api/routes/old/reports.py deleted file mode 100644 index ff13ca73..00000000 --- a/api/routes/old/reports.py +++ /dev/null @@ -1,67 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from datetime import date, datetime -from typing import List - -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session -from starlette.responses import FileResponse - -from api.schemas import meter_schemas -from api.models import Repair, Meters, Well, MeterHistory, Organizations, MeterStatusLU -from api.security import scoped_user -from api.session import get_db -from api.xls_persistence import make_xls_backup - -report_user = scoped_user(["read", "reports:run"]) -report_router = APIRouter(dependencies=[Depends(report_user)]) - - -@report_router.get( - "/repair_report", response_model=List[meter_schemas.RepairReport], tags=["reports"] -) -def read_repair_report( - after_date: date = None, - after: datetime = None, - db: Session = Depends(get_db), -): - q = db.query(Repair) - if after_date: - q = q.filter(Repair.timestamp > datetime.fromordinal(after_date.toordinal())) - elif after: - q = q.filter(Repair.timestamp > after) - - return q.all() - - -@report_router.get("/xls_backup", tags=["reports"]) -async def get_xls_backup(db: Session = Depends(get_db)): - path = make_xls_backup( - db, - ( - Meters, - Well, - Organizations, - MeterHistory, - MeterStatusLU, - ), - ) - return FileResponse( - path=path, media_type="application/octet-stream", filename="backup.xlsx" - ) - - -# ============= EOF ============================================= diff --git a/api/routes/old/wells.py b/api/routes/old/wells.py deleted file mode 100644 index e6274545..00000000 --- a/api/routes/old/wells.py +++ /dev/null @@ -1,106 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from typing import List - -from fastapi import APIRouter, Depends -from sqlalchemy import func -from sqlalchemy.orm import Session - -from api.schemas import meter_schemas -from api.models import Well, WellConstruction -from api.route_util import _patch, _add -from api.security import scoped_user -from api.session import get_db - -well_router = APIRouter() - -write_user = scoped_user(["read", "wells:write"]) - - -@well_router.get( - "/wellconstruction/{well_id}", - response_model=meter_schemas.WellConstruction, - tags=["wells"], -) -async def read_wellconstruction(well_id, db: Session = Depends(get_db)): - return db.query(WellConstruction).filter_by(well_id=well_id).first() - - -@well_router.get("/wells", response_model=List[meter_schemas.Well], tags=["wells"]) -def read_wells( - radius: float = None, - latlng: str = None, - osepod: str = None, - township: int = None, - range_: int = None, - section: int = None, - quarter: int = None, - half_quarter: int = None, - db: Session = Depends(get_db), -): - """ - radius in kilometers - - :return: - """ - q = db.query(Well) - if radius and latlng: - latlng = latlng.split(",") - radius = radius / 111.139 - q = q.filter( - func.ST_DWithin(Well.geom, f"POINT ({latlng[1]} {latlng[0]})", radius) - ) - - if osepod: - q = q.filter(Well.osepod.like(f"%{osepod}%")) - if township: - q = q.filter(Well.township == township) - if range_: - q = q.filter(Well.range == range_) - if section: - q = q.filter(Well.section == section) - - if quarter: - q = q.filter(Well.quarter == quarter) - if half_quarter: - q = q.filter(Well.half_quarter == half_quarter) - - return q.all() - - -@well_router.patch( - "/wells/{well_id}", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Well, - tags=["wells"], -) -async def patch_wells( - well_id: int, obj: meter_schemas.Well, db: Session = Depends(get_db) -): - return _patch(db, Well, well_id, obj) - - -@well_router.post( - "/wells", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Well, - tags=["wells"], -) -async def add_well(obj: meter_schemas.WellCreate, db: Session = Depends(get_db)): - return _add(db, Well, obj) - - -# ============= EOF ============================================= diff --git a/api/routes/old/workers.py b/api/routes/old/workers.py deleted file mode 100644 index 08d18d4c..00000000 --- a/api/routes/old/workers.py +++ /dev/null @@ -1,76 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from typing import List - -from fastapi import Depends, APIRouter, HTTPException -from sqlalchemy.orm import Session - -from api.schemas import meter_schemas -from api.models import Meters, Worker -from api.route_util import _add, _patch -from api.security import scoped_user -from api.session import get_db - -worker_router = APIRouter() - -write_user = scoped_user(["read", "workers:write"]) - - -@worker_router.get( - "/workers", response_model=List[meter_schemas.Worker], tags=["workers"] -) -def read_workers(db: Session = Depends(get_db)): - return db.query(Worker).all() - - -@worker_router.post( - "/workers", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Worker, - tags=["workers"], -) -async def add_worker( - worker: meter_schemas.WorkerCreate, - db: Session = Depends(get_db), -): - return _add(db, Worker, worker) - - -@worker_router.patch( - "/workers/{worker_id}", - dependencies=[Depends(write_user)], - response_model=meter_schemas.Worker, - tags=["workers"], -) -async def patch_worker( - worker_id: int, worker: meter_schemas.Worker, db: Session = Depends(get_db) -): - return _patch(db, Worker, worker_id, worker) - - -@worker_router.delete( - "/workers/{worker_id}", dependencies=[Depends(write_user)], tags=["workers"] -) -async def delete_worker(worker_id: int, db: Session = Depends(get_db)): - worker = db.get(Worker, worker_id) - if not worker: - raise HTTPException(status_code=404, detail="Worker not found") - db.delete(worker) - db.commit() - return {"ok": True} - - -# ============= EOF ============================================= diff --git a/api/routes/settings.py b/api/routes/settings.py new file mode 100644 index 00000000..4e94ae38 --- /dev/null +++ b/api/routes/settings.py @@ -0,0 +1,72 @@ +from fastapi import Depends, APIRouter, HTTPException +from sqlalchemy.orm import Session +from api.schemas.base import ORMBase +from api.session import get_db +from api.security import get_current_user +from api.models.main_models import Users + + +settings_router = APIRouter() + + +@settings_router.get( + "/settings/redirect_page", + tags=["settings"], +) +def get_redirect_page( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + return {"redirect_page": db_user.redirect_page} + + +class RedirectPageUpdate(ORMBase): + redirect_page: str + + +@settings_router.post( + "/settings/redirect_page", + tags=["settings"], +) +def post_redirect_page( + update: RedirectPageUpdate, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + db_user.redirect_page = update.redirect_page + db.commit() + db.refresh(db_user) + + return {"message": "Redirect page updated", "redirect_page": db_user.redirect_page} + + +class DisplayNameUpdate(ORMBase): + display_name: str + + +@settings_router.post( + "/settings/display_name", + tags=["settings"], +) +def post_redirect_page( + update: DisplayNameUpdate, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + db_user.display_name = update.display_name + db.commit() + db.refresh(db_user) + + return {"message": "Display name updated", "display_name": db_user.display_name} diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 10baa025..7f19b7f5 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -32,10 +32,11 @@ autoescape=select_autoescape(["html", "xml"]) ) -well_measurement_router = APIRouter() +authenticated_well_measurement_router = APIRouter() +public_well_measurement_router = APIRouter() -@well_measurement_router.post( +@authenticated_well_measurement_router.post( "/waterlevels", dependencies=[Depends(ScopedUser.WellMeasurementWrite)], response_model=well_schemas.WellMeasurement, @@ -66,9 +67,8 @@ def add_waterlevel( return well_measurement -@well_measurement_router.get( +@public_well_measurement_router.get( "/waterlevels", - dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.WellMeasurementDTO], tags=["WaterLevels"], ) @@ -201,7 +201,7 @@ def add_year_average(year: int, label: str): return response_data -@well_measurement_router.get( +@authenticated_well_measurement_router.get( "/waterlevels/pdf", dependencies=[Depends(ScopedUser.Read)], tags=["WaterLevels"], @@ -430,7 +430,7 @@ def make_line_chart(data: dict, title: str): ) -@well_measurement_router.patch( +@authenticated_well_measurement_router.patch( "/waterlevels", dependencies=[Depends(ScopedUser.Admin)], response_model=well_schemas.WellMeasurement, @@ -451,7 +451,7 @@ def patch_waterlevel(waterlevel_patch: well_schemas.PatchWaterLevel, db: Session return well_measurement -@well_measurement_router.delete( +@authenticated_well_measurement_router.delete( "/waterlevels", dependencies=[Depends(ScopedUser.Admin)], tags=["WaterLevels"], diff --git a/api/routes/wells.py b/api/routes/wells.py index e9be8421..95130445 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -13,10 +13,11 @@ from api.session import get_db from api.enums import ScopedUser, WellSortByField, SortDirection -well_router = APIRouter() +public_well_router = APIRouter() +authenticated_well_router = APIRouter() -@well_router.get( +@authenticated_well_router.get( "/use_types", dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.WellUseLU], @@ -28,7 +29,7 @@ def get_use_types( return db.scalars(select(WellUseLU)).all() # Get water sources -@well_router.get( +@authenticated_well_router.get( "/water_sources", dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.WaterSources], @@ -40,7 +41,7 @@ def get_water_sources( return db.scalars(select(WaterSources)).all() # Get well status types -@well_router.get( +@authenticated_well_router.get( "/well_status_types", dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.WellStatus], @@ -52,9 +53,8 @@ def get_well_status_types( return db.scalars(select(WellStatus)).all() -@well_router.get( +@public_well_router.get( "/wells", - dependencies=[Depends(ScopedUser.Read)], response_model=LimitOffsetPage[well_schemas.WellResponse], tags=["Wells"], ) @@ -124,7 +124,7 @@ def sort_by_field_to_schema_field(name: WellSortByField): return paginate(db, query_statement) -@well_router.patch( +@authenticated_well_router.patch( "/wells", dependencies=[Depends(ScopedUser.WellWrite)], response_model=well_schemas.WellResponse, @@ -184,7 +184,7 @@ def update_well( return updated_well_model -@well_router.post( +@authenticated_well_router.post( "/wells", dependencies=[Depends(ScopedUser.Admin)], tags=["Wells"], @@ -237,7 +237,7 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g return new_well_model -@well_router.get( +@authenticated_well_router.get( "/well_locations", dependencies=[Depends(ScopedUser.Read)], response_model=List[well_schemas.WellResponse], @@ -277,7 +277,7 @@ def get_wells_locations( return db.scalars(query_statement.offset(offset).limit(limit)).all() -@well_router.get( +@authenticated_well_router.get( "/well", dependencies=[Depends(ScopedUser.Read)], response_model=well_schemas.Well, @@ -292,7 +292,7 @@ def get_well(well_id: int, db: Session = Depends(get_db)): .filter(Wells.id == well_id) ).first() -@well_router.post( +@authenticated_well_router.post( "/merge_wells", dependencies=[Depends(ScopedUser.Admin)], tags=["Wells"], diff --git a/api/schemas/meter_schemas.py b/api/schemas/meter_schemas.py index fbc8d39f..3fabc9e9 100644 --- a/api/schemas/meter_schemas.py +++ b/api/schemas/meter_schemas.py @@ -3,6 +3,7 @@ from api.schemas.well_schemas import Well, Location from api.schemas.security_schemas import User from pydantic import BaseModel +from decimal import Decimal class Unit(ORMBase): name: str | None = None @@ -52,6 +53,7 @@ class StatusDTO(ORMBase): id: int serial_number: str water_users: str | None = None + price: Decimal | None = None well: WellDTO | None = None location: LocationDTO | None = None status: StatusDTO | None = None @@ -93,6 +95,7 @@ class SubmitNewMeter(ORMBase): contact_name: str | None = None contact_phone: str | None = None notes: str | None = None + price: Decimal | None = None well: Well | None = None meter_register: MeterRegister | None = None @@ -102,6 +105,7 @@ class SubmitMeterUpdate(ORMBase): contact_name: str | None = None contact_phone: str | None = None notes: str | None = None + price: Decimal | None = None meter_type: MeterTypeLU status: MeterStatusLU | None = None meter_register: MeterRegister | None = None @@ -203,12 +207,17 @@ class Notes(ORMBase): part_used_ids: list[int] | None = None - class ActivityTypeLU(ORMBase): name: str | None = None description: str | None = None +class MeterActivityPhoto(ORMBase): + id: int + file_name: str + gcs_path: str + uploaded_at: datetime + class MeterActivity(ORMBase): timestamp_start: datetime timestamp_end: datetime @@ -221,6 +230,8 @@ class MeterActivity(ORMBase): ose_share: bool water_users: str | None = None + photos: list[MeterActivityPhoto] = [] + class PatchActivity(ORMBase): activity_id: int diff --git a/api/schemas/security_schemas.py b/api/schemas/security_schemas.py index cc000a56..27201f8d 100644 --- a/api/schemas/security_schemas.py +++ b/api/schemas/security_schemas.py @@ -30,6 +30,7 @@ class NewUser(ORMBase): username: str email: str full_name: str + display_name: str disabled: bool user_role_id: int password: str @@ -45,6 +46,10 @@ class User(ORMBase): user_role: UserRole | None = None + display_name: str | None = None + redirect_page: str | None = None + avatar_img: str | None = None + class Token(BaseModel): access_token: str diff --git a/api/templates/chlorides_report.html b/api/templates/chlorides_report.html index 1e15688e..0e77eebf 100644 --- a/api/templates/chlorides_report.html +++ b/api/templates/chlorides_report.html @@ -39,6 +39,8 @@

Chloride Report

Min Max Average + Median + Count @@ -47,24 +49,32 @@

Chloride Report

{{ report.north.min }} {{ report.north.max }} {{ "%.2f"|format(report.north.avg or 0) }} + {{ report.north.median }} + {{ report.north.count }} South {{ report.south.min }} {{ report.south.max }} {{ "%.2f"|format(report.south.avg or 0) }} + {{ report.south.median }} + {{ report.south.count }} East {{ report.east.min }} {{ report.east.max }} {{ "%.2f"|format(report.east.avg or 0) }} + {{ report.east.median }} + {{ report.east.count }} West {{ report.west.min }} {{ report.west.max }} {{ "%.2f"|format(report.west.avg or 0) }} + {{ report.west.median }} + {{ report.west.count }} diff --git a/docker-compose.development.yml b/docker-compose.development.yml index d34cfc80..6f5ec224 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -33,6 +33,7 @@ services: environment: - GCP_BUCKET_NAME=pvacd - GCP_BACKUP_PREFIX=pre-prod-db-backups + - GCP_PHOTO_PREFIX=pre-prod-meter-activities-photos - BACKUP_RETENTION_DAYS=14 - APPDB_ENV=.env_devserver command: > diff --git a/docker-compose.production.yml b/docker-compose.production.yml index c45ec0de..8266cc69 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -33,6 +33,7 @@ services: environment: - GCP_BUCKET_NAME=pvacd - GCP_BACKUP_PREFIX=prod-db-backups + - GCP_PHOTO_PREFIX=prod-meter-activities-photos - BACKUP_RETENTION_DAYS=90 - APPDB_ENV=.env_production command: > diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9ea3c7e9..d73665f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,8 @@ "license": "Apache-2.0", "dependencies": { "@changey/react-leaflet-markercluster": "^4.0.0-rc1", + "@dicebear/collection": "^9.2.4", + "@dicebear/core": "^9.2.4", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", @@ -225,6 +227,422 @@ "findup": "bin/findup.js" } }, + "node_modules/@dicebear/adventurer": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.4.tgz", + "integrity": "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/adventurer-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.4.tgz", + "integrity": "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.4.tgz", + "integrity": "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.4.tgz", + "integrity": "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.4.tgz", + "integrity": "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.4.tgz", + "integrity": "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.4.tgz", + "integrity": "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.4.tgz", + "integrity": "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.4.tgz", + "integrity": "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/collection": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.4.tgz", + "integrity": "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA==", + "license": "MIT", + "dependencies": { + "@dicebear/adventurer": "9.2.4", + "@dicebear/adventurer-neutral": "9.2.4", + "@dicebear/avataaars": "9.2.4", + "@dicebear/avataaars-neutral": "9.2.4", + "@dicebear/big-ears": "9.2.4", + "@dicebear/big-ears-neutral": "9.2.4", + "@dicebear/big-smile": "9.2.4", + "@dicebear/bottts": "9.2.4", + "@dicebear/bottts-neutral": "9.2.4", + "@dicebear/croodles": "9.2.4", + "@dicebear/croodles-neutral": "9.2.4", + "@dicebear/dylan": "9.2.4", + "@dicebear/fun-emoji": "9.2.4", + "@dicebear/glass": "9.2.4", + "@dicebear/icons": "9.2.4", + "@dicebear/identicon": "9.2.4", + "@dicebear/initials": "9.2.4", + "@dicebear/lorelei": "9.2.4", + "@dicebear/lorelei-neutral": "9.2.4", + "@dicebear/micah": "9.2.4", + "@dicebear/miniavs": "9.2.4", + "@dicebear/notionists": "9.2.4", + "@dicebear/notionists-neutral": "9.2.4", + "@dicebear/open-peeps": "9.2.4", + "@dicebear/personas": "9.2.4", + "@dicebear/pixel-art": "9.2.4", + "@dicebear/pixel-art-neutral": "9.2.4", + "@dicebear/rings": "9.2.4", + "@dicebear/shapes": "9.2.4", + "@dicebear/thumbs": "9.2.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz", + "integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.11" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.4.tgz", + "integrity": "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/croodles-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.4.tgz", + "integrity": "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/dylan": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.4.tgz", + "integrity": "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.4.tgz", + "integrity": "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/glass": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.4.tgz", + "integrity": "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/icons": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.4.tgz", + "integrity": "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.4.tgz", + "integrity": "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz", + "integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.4.tgz", + "integrity": "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.4.tgz", + "integrity": "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.4.tgz", + "integrity": "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/miniavs": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.4.tgz", + "integrity": "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.4.tgz", + "integrity": "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.4.tgz", + "integrity": "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/open-peeps": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.4.tgz", + "integrity": "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/personas": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.4.tgz", + "integrity": "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.4.tgz", + "integrity": "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.4.tgz", + "integrity": "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.4.tgz", + "integrity": "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.4.tgz", + "integrity": "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.4.tgz", + "integrity": "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6650a1e8..a553f1d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@changey/react-leaflet-markercluster": "^4.0.0-rc1", + "@dicebear/collection": "^9.2.4", + "@dicebear/core": "^9.2.4", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 24bdd003..1039798a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,8 @@ import { PartsUsedReportView } from "./views/Reports/PartsUsed"; import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; import { AppLayout } from "./AppLayout"; +import { NotFound } from "./views/NotFound"; +import { ProtectedRoute } from "./ProtectedRoute"; export const App = () => { const queryClient = new QueryClient(); @@ -60,188 +62,181 @@ export const App = () => { } - requiredScopes={[]} - setErrorMessage={setErrorMessage} - /> + + + } /> - } - requiredScopes={[]} - setErrorMessage={setErrorMessage} - /> - } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + } /> } - requiredScopes={["activities:write"]} - setErrorMessage={setErrorMessage} - /> + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> + + + + + } + /> + } - requiredScopes={["admin"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["admin"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["admin"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + + + } /> } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> + + + } /> diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 98281aaf..0a9aaf78 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -1,74 +1,17 @@ -import { useAuthUser } from "react-auth-kit"; -import { useEffect, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useState } from "react"; import { Box } from "@mui/material"; -import { SecurityScope } from "./interfaces"; import Topbar from "./components/Topbar"; import Sidenav from "./sidenav"; const drawerWidth = 250; export const AppLayout = ({ - pageComponent, - requiredScopes = null, - setErrorMessage = null, -}: any) => { - const authUser = useAuthUser(); - const navigate = useNavigate(); - const location = useLocation(); - - const isLoggedIn = authUser() != null; - const userScopes: string[] = - authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string - ) ?? []; - - const hasScopes = - requiredScopes == null - ? true - : requiredScopes?.every((scope: string) => - userScopes?.includes(scope) - ); - + children, +}: { + children: JSX.Element +}) => { const [drawerOpen, setDrawerOpen] = useState(false); - useEffect(() => { - const currentPath = location.pathname; - - // Case 1: Not logged in - if (!isLoggedIn) { - const allowedRoutes = ["/", "/login"]; - if (!allowedRoutes.includes(currentPath)) { - if (setErrorMessage) - setErrorMessage("You must login to view pages."); - navigate("/login", { replace: true }); - } - return; - } - - // Case 2: Logged in but no scopes at all - if (userScopes.length === 0) { - const allowedRoutes = ["/", "/login"]; - if (!allowedRoutes.includes(currentPath)) { - if (setErrorMessage) - setErrorMessage( - "Your account does not have any permissions to view this page." - ); - navigate("/", { replace: true }); - } - return; - } - - // Case 3: Logged in but missing required scopes - if (!hasScopes) { - if (setErrorMessage) - setErrorMessage( - "You do not have sufficient permissions to view this page." - ); - navigate("/", { replace: true }); - } - }, [isLoggedIn, hasScopes, userScopes, location.pathname]); - return ( - {pageComponent} + {children} ); diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx new file mode 100644 index 00000000..d607e2e6 --- /dev/null +++ b/frontend/src/ProtectedRoute.tsx @@ -0,0 +1,43 @@ +import { Navigate } from "react-router-dom"; +import { useAuthUser, useIsAuthenticated } from "react-auth-kit"; +import { SecurityScope } from "./interfaces"; + +export const ProtectedRoute = ({ + children, + requiredScopes, + setErrorMessage, +}: { + children: JSX.Element; + requiredScopes?: string[]; + setErrorMessage?: (msg: string) => void; +}) => { + const isAuthenticated = useIsAuthenticated(); + const authUser = useAuthUser(); + + // Case 1: Not logged in + if (!isAuthenticated()) { + if (setErrorMessage) setErrorMessage("You must login to view this page."); + return ; + } + + // Case 2: Logged in but no scopes + const userScopes: string[] = + authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string + ) ?? []; + + if (userScopes.length === 0) { + if (setErrorMessage) + setErrorMessage("Your account does not have any permissions."); + return ; + } + + // Case 3: Missing required scopes + if (requiredScopes && !requiredScopes.every((s) => userScopes.includes(s))) { + if (setErrorMessage) + setErrorMessage("You do not have sufficient permissions."); + return ; + } + + return children; +}; diff --git a/frontend/src/assets/leaflet/marker-icon-black.png b/frontend/src/assets/leaflet/marker-icon-black.png new file mode 100644 index 00000000..d262ae42 Binary files /dev/null and b/frontend/src/assets/leaflet/marker-icon-black.png differ diff --git a/frontend/src/components/AvatarPicker.tsx b/frontend/src/components/AvatarPicker.tsx new file mode 100644 index 00000000..4540e823 --- /dev/null +++ b/frontend/src/components/AvatarPicker.tsx @@ -0,0 +1,141 @@ +import { useState, useEffect } from "react"; +import { Grid, Box, Card, CardActionArea, Button, Typography } from "@mui/material"; +import { createAvatar } from "@dicebear/core"; +import { loreleiNeutral, initials } from "@dicebear/collection"; + +type AvatarPickerProps = { + onSelect: (avatar: string) => void; + initialSeed?: string; + display_name: string; +}; + +export default function AvatarPicker({ + onSelect, + initialSeed, + display_name, +}: AvatarPickerProps) { + const [avatars, setAvatars] = useState([]); + const [selected, setSelected] = useState(null); + + const generateAvatars = () => { + // Lorelei batch: random seed + const batchLorelei = Array.from({ length: 10 }, () => { + const seed = Math.random().toString(36).substring(2, 10); + return createAvatar(loreleiNeutral, { + size: 64, + seed, + }).toDataUri(); + }); + + // Initials batch: always use display_name, but vary style + const batchInitials = Array.from({ length: 2 }, () => { + const size = 64 + Math.floor(Math.random() * 20) - 10; // vary ±10 + const bgColors = [ + // Greys (dark enough for white contrast) + "424242", "616161", "757575", "546e7a", "455a64", + + // Blues (pair with pink secondary) + "1565c0", "1976d2", "1e88e5", "283593", "303f9f", + + // Teals & Cyans + "00838f", "0097a7", "00695c", "00796b", + + // Greens + "2e7d32", "388e3c", "43a047", "1b5e20", + + // Yellows & Ambers (pick deeper tones so white is readable) + "f57f17", "f9a825", "ff8f00", "ff6f00", + + // Oranges + "e65100", "ef6c00", "f4511e", "d84315", + + // Reds / Pinks (echo secondary) + "ad1457", "c2185b", "d81b60", "b71c1c", "c62828", + + // Purples (complement to indigo) + "6a1b9a", "7b1fa2", "8e24aa", "512da8", "5e35b1", + + // Indigo (close to primary but a bit varied) + "283593", "3949ab", "303f9f" + ]; + const backgroundColor = + bgColors[Math.floor(Math.random() * bgColors.length)]; + + return createAvatar(initials, { + size, + seed: display_name, // 👈 initials come from display_name + backgroundColor: [backgroundColor], + }).toDataUri(); + }); + + // Shuffle them together for variety + const mixed = [...batchLorelei, ...batchInitials].sort(() => Math.random() - 0.5); + + setAvatars(mixed); + setSelected(null); + }; + + // Generate initial batch on mount + useEffect(() => { + generateAvatars(); + }, []); + + // If initialSeed provided, generate that avatar as the selected one + useEffect(() => { + if (initialSeed) { + const avatar = createAvatar(loreleiNeutral, { + size: 64, + seed: initialSeed, + }).toDataUri(); + setSelected(avatar); + } + }, [initialSeed]); + + const handleSelect = (avatar: string) => { + setSelected(avatar); + onSelect(avatar); + }; + + return ( + + + Choose Your Avatar + + + {avatars.map((avatar, i) => ( + + + handleSelect(avatar)}> + + + + + ))} + + + + + + + ); +} diff --git a/frontend/src/components/DirectionCard.tsx b/frontend/src/components/DirectionCard.tsx index c046f287..6fe0c28e 100644 --- a/frontend/src/components/DirectionCard.tsx +++ b/frontend/src/components/DirectionCard.tsx @@ -6,11 +6,15 @@ export const DirectionCard = ({ min, avg, max, + median, + count, }: { title: string; min?: number; avg?: number; max?: number; + median?: number; + count?: number; }) => { return ( @@ -21,8 +25,10 @@ export const DirectionCard = ({ - + + + diff --git a/frontend/src/components/ImageDialog.tsx b/frontend/src/components/ImageDialog.tsx new file mode 100644 index 00000000..11e4f2e3 --- /dev/null +++ b/frontend/src/components/ImageDialog.tsx @@ -0,0 +1,46 @@ +import { Dialog, DialogContent, IconButton, Box } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; + +export const ImageDialog = ({ + open, + src, + onClose, +}: { + open: boolean; + src: string | null; + onClose: () => void; +}) => { + return ( + + + + + + {src && ( + + )} + + + ); +}; diff --git a/frontend/src/components/ImagePreviewGrid.tsx b/frontend/src/components/ImagePreviewGrid.tsx new file mode 100644 index 00000000..9f28fd56 --- /dev/null +++ b/frontend/src/components/ImagePreviewGrid.tsx @@ -0,0 +1,60 @@ +import { memo } from "react"; +import { Box, IconButton } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; + +export const ImagePreviewGrid = memo(({ previews, onRemove, onOpen }: { + previews: string[]; + onRemove?: (index: number) => void; + onOpen?: (src: string) => void; +}) => { + return ( + + {previews.map((src, i) => { + return ( + onOpen?.(src)} + > + + {onRemove && ( + onRemove(i)} + sx={{ + position: "absolute", + top: 0, + right: 0, + backgroundColor: "rgba(255,255,255,0.7)", + border: "1px solid black", + "&:hover": { + backgroundColor: "rgba(255,0,0,0.8)", + color: "white", + }, + }} + > + + + )} + + ); + })} + + ); +}); diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx new file mode 100644 index 00000000..98abf5de --- /dev/null +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { Grid, Button, Typography, Box } from "@mui/material"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import { ImageDialog, ImagePreviewGrid } from "./"; +import { enqueueSnackbar } from "notistack"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB + +const VisuallyHiddenInput = (props: any) => ( + +); + +export const ImageUploadWithPreview = ({ + onFilesChange, + fileLimit, +}: { + onFilesChange?: (files: File[]) => void; + fileLimit?: number; +}) => { + const [previews, setPreviews] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [files, setFiles] = useState([]); + + const handleFileChange = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files) return; + + let imageFiles = Array.from(files).filter((file) => + file.type.startsWith("image/") + ); + + // enforce max file size + const tooBig = imageFiles.filter((f) => f.size > MAX_FILE_SIZE); + if (tooBig.length > 0) { + enqueueSnackbar( + `Some files are too large. Max allowed size is ${MAX_FILE_SIZE / 1024 / 1024} MB.`, + { variant: "error" } + ); + imageFiles = imageFiles.filter((f) => f.size <= MAX_FILE_SIZE); + } + + // enforce file limit + setFiles((prev) => { + let updated = [...prev]; + + if (fileLimit) { + const remaining = fileLimit - updated.length; + + if (remaining <= 0) { + enqueueSnackbar(`You can only upload up to ${fileLimit} images.`, { + variant: "warning", + }); + event.target.value = ""; + return updated; // no changes + } + + if (imageFiles.length > remaining) { + enqueueSnackbar(`Only ${remaining} more image${remaining > 1 ? "s" : ""} allowed.`, { + variant: "info", + }); + imageFiles = imageFiles.slice(0, remaining); + } + } + + updated = [...updated, ...imageFiles]; + onFilesChange?.(updated); + + // set previews too + const urls = imageFiles.map((file) => URL.createObjectURL(file)); + setPreviews((prevPreviews) => [...prevPreviews, ...urls]); + + return updated; + }); + + event.target.value = ""; + }; + + const handleRemove = (index: number) => { + setFiles((prev) => { + const updated = [...prev]; + updated.splice(index, 1); + onFilesChange?.(updated); + return updated; + }); + + setPreviews((prev) => { + const updated = [...prev]; + const [removed] = updated.splice(index, 1); + URL.revokeObjectURL(removed); // free memory + return updated; + }); + }; + + return ( + + + + {fileLimit && ( + + {files.length}/{fileLimit} images uploaded + + )} + + {previews.length > 0 && ( + <> + { + setSelectedImage(src); + setDialogOpen(true); + }} + /> + setDialogOpen(false)} + /> + + )} + + ); +} + diff --git a/frontend/src/components/IsTrueChip.tsx b/frontend/src/components/IsTrueChip.tsx new file mode 100644 index 00000000..1b19d20b --- /dev/null +++ b/frontend/src/components/IsTrueChip.tsx @@ -0,0 +1,9 @@ +import { Chip } from "@mui/material"; + +export const IsTrueChip = ({ assert }: { assert: boolean }) => { + return assert ? ( + + ) : ( + + ); +} diff --git a/frontend/src/components/MapIcons/Black.tsx b/frontend/src/components/MapIcons/Black.tsx new file mode 100644 index 00000000..2b0946ed --- /dev/null +++ b/frontend/src/components/MapIcons/Black.tsx @@ -0,0 +1,12 @@ +import L from "leaflet"; +import iconBlack from "./../../assets/leaflet/marker-icon-black.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +export const BlackMapIcon = L.icon({ + iconUrl: iconBlack, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/frontend/src/components/MapIcons/Blue.tsx b/frontend/src/components/MapIcons/Blue.tsx new file mode 100644 index 00000000..1afb6829 --- /dev/null +++ b/frontend/src/components/MapIcons/Blue.tsx @@ -0,0 +1,12 @@ +import L from "leaflet"; +import iconBlue from "leaflet/dist/images/marker-icon.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +export const BlueMapIcon = L.icon({ + iconUrl: iconBlue, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/frontend/src/components/MapIcons/Red.tsx b/frontend/src/components/MapIcons/Red.tsx new file mode 100644 index 00000000..5e8c9352 --- /dev/null +++ b/frontend/src/components/MapIcons/Red.tsx @@ -0,0 +1,12 @@ +import L from "leaflet"; +import iconRed from "./../../assets/leaflet/marker-icon-red.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +export const RedMapIcon = L.icon({ + iconUrl: iconRed, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/frontend/src/components/MapIcons/index.ts b/frontend/src/components/MapIcons/index.ts new file mode 100644 index 00000000..0156b7a1 --- /dev/null +++ b/frontend/src/components/MapIcons/index.ts @@ -0,0 +1,3 @@ +export * from "./Black" +export * from "./Blue" +export * from "./Red" diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index 830c2edd..f6541404 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -1,58 +1,63 @@ -import { Grid, SvgIconProps, Box, Typography } from "@mui/material"; +import { SvgIconProps, Badge, ListItem, ListItemButton, ListItemIcon, ListItemText } from "@mui/material"; import TableViewIcon from "@mui/icons-material/TableView"; -import { Link, useLocation } from "react-router-dom"; +import { Link, type LinkProps } from "react-router-dom"; +import { useIsActiveRoute } from "../hooks"; export const NavLink = ({ disabled = false, route, label, - Icon, + icon: Icon, + badgeContent, + subItem = false, }: { disabled?: boolean; - route: string; + route: LinkProps["to"]; label: string; - Icon?: React.ComponentType; + icon?: React.ComponentType; + badgeContent?: number; + subItem?: boolean; }) => { - const location = useLocation(); - const isActive = location.pathname === route; - - const content = ( - - {Icon ? ( - - ) : ( - - )} - {label} - - ); + const isActive = useIsActiveRoute(route); return ( - - {disabled ? ( - content - ) : ( - - {content} - - )} - + + + + {Icon ? ( + + + + ) : ( + + )} + + + + ); }; diff --git a/frontend/src/components/RHControlled/ControlledTimepicker.tsx b/frontend/src/components/RHControlled/ControlledTimepicker.tsx index 25d0025e..53f2cd85 100644 --- a/frontend/src/components/RHControlled/ControlledTimepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledTimepicker.tsx @@ -14,7 +14,12 @@ export default function ControlledTimepicker({ )} diff --git a/frontend/src/components/ReportsNavItem.tsx b/frontend/src/components/ReportsNavItem.tsx new file mode 100644 index 00000000..2de81665 --- /dev/null +++ b/frontend/src/components/ReportsNavItem.tsx @@ -0,0 +1,72 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { + Assessment, + ExpandLess, + ExpandMore, +} from "@mui/icons-material"; +import { useNavigate } from "react-router-dom"; +import { useIsActiveRoute } from "../hooks"; + +export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Dispatch> }) { + const navigate = useNavigate(); + const [clickTimer, setClickTimer] = useState(null); + const isActive = useIsActiveRoute("/reports"); + + const handleClick = () => { + if (clickTimer) { + clearTimeout(clickTimer); + setClickTimer(null); + } + const timer = setTimeout(() => { + setOpen((prev) => !prev); + setClickTimer(null); + }, 200); + setClickTimer(timer); + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + if (clickTimer) { + clearTimeout(clickTimer); + setClickTimer(null); + } + e.stopPropagation(); + setOpen(false); + navigate("/reports"); + }; + + return ( + + + + + + + {open ? : } + + + ); +} + diff --git a/frontend/src/components/RoleChip.tsx b/frontend/src/components/RoleChip.tsx new file mode 100644 index 00000000..431e40b1 --- /dev/null +++ b/frontend/src/components/RoleChip.tsx @@ -0,0 +1,15 @@ +import { Chip } from "@mui/material"; + +export const RoleChip = ({ role }: { role: string }) => { + switch (role) { + case "Admin": { + return ; + } + case "Technician": { + return ; + } + default: { + return ; + } + } +} diff --git a/frontend/src/components/StatCell.tsx b/frontend/src/components/StatCell.tsx index 3fd8f43a..954ac962 100644 --- a/frontend/src/components/StatCell.tsx +++ b/frontend/src/components/StatCell.tsx @@ -1,13 +1,13 @@ import { Stack, Typography } from "@mui/material"; import { formatNumberData } from "../utils"; -export const StatCell = ({ label, value }: { label: string; value?: number }) => { +export const StatCell = ({ label, value, isCount }: { label: string; value?: number, isCount?: boolean }) => { return ( {label} - {formatNumberData(value)} ppm + {formatNumberData(value)}{isCount ? "" : " ppm"} ); } diff --git a/frontend/src/components/StyledToggleButton.tsx b/frontend/src/components/StyledToggleButton.tsx new file mode 100644 index 00000000..d80fc909 --- /dev/null +++ b/frontend/src/components/StyledToggleButton.tsx @@ -0,0 +1,28 @@ +import { ToggleButton, ToggleButtonProps } from "@mui/material"; + +const defaultToggleStyle = { + "&.Mui-selected": { borderColor: "blue", border: 1 }, +}; + +export const StyledToggleButton = (props: ToggleButtonProps) => { + const { + children, + value = "check", + color = "primary", + fullWidth = true, + sx, + ...rest + } = props; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index a8163ee8..13ec25d5 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { AppBar, Toolbar, @@ -9,22 +10,24 @@ import { Button, Box, Divider, + ListItemIcon, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import { useNavigate } from "react-router-dom"; import { useAuthUser, useSignOut } from "react-auth-kit"; -import { useState } from "react"; -import { Badge, Engineering, Face, Login } from "@mui/icons-material"; +import { Login, Logout, Settings } from "@mui/icons-material"; +import { RoleChip, TopbarUserButton } from "./index"; export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMenuClick: () => void; sx?: any }) { const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); - const role = authUser()?.user_role?.name; - const isLoggedIn = !!authUser(); const [anchorEl, setAnchorEl] = useState(null); + const role: string = authUser()?.user_role?.name; + const isLoggedIn = !!authUser(); + const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -35,20 +38,10 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen const fullSignOut = () => { navigate("/"); + localStorage.removeItem("loggedIn"); signOut(); }; - const renderRoleIcon = () => { - switch (role) { - case "Admin": - return ; - case "Technician": - return ; - default: - return ; - } - }; - return ( - + src={authUser()?.avatar_img} + /> - - - - Role: {role ?? "Unknown"} - - - + + Role: + + + { @@ -147,12 +120,23 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen handleMenuClose() }} > - Settings + + + + Account Settings + + + { + fullSignOut() + handleMenuClose() + }} + > + + + + Logout - { - fullSignOut() - handleMenuClose() - }}>Logout ) @@ -161,6 +145,7 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen onClick={() => navigate("/login")} sx={{ textTransform: "uppercase", + fontFamily: "monospace", fontWeight: "bolder", backgroundColor: "darkblue", color: "white", @@ -172,10 +157,11 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen Login diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx new file mode 100644 index 00000000..5cbd6fab --- /dev/null +++ b/frontend/src/components/TopbarUserButton.tsx @@ -0,0 +1,73 @@ +import { Avatar, Button, ButtonProps } from "@mui/material"; +import { getRoleColor } from "../utils"; +import { Badge, Engineering, Face } from "@mui/icons-material"; +import { useTheme } from "@mui/material/styles"; + + +export const TopbarUserButton = ({ + display_name, + role, + src, + ...buttonProps +}: { + display_name: string, + role: string, + src?: string +} & ButtonProps) => { + const theme = useTheme(); + const buttonColor = getRoleColor(role); + + const primary = theme.palette.primary; + const secondary = theme.palette.secondary; + const warning = theme.palette.warning; + + const roleIcons: Record = { + Admin: , + Technician: , + }; + + const renderRoleIcon = () => roleIcons[role] ?? ; + + const roleBgColor: Record = { + Admin: primary.dark, + Technician: secondary.dark, + OSE: warning.dark + } + + const roleBorderColor: Record = { + Admin: primary.contrastText, + Technician: secondary.contrastText, + OSE: warning.contrastText + } + + return ( + + ); +} diff --git a/frontend/src/components/WellMapLegend.tsx b/frontend/src/components/WellMapLegend.tsx index d6a5e8d3..ba5bf5c6 100644 --- a/frontend/src/components/WellMapLegend.tsx +++ b/frontend/src/components/WellMapLegend.tsx @@ -1,26 +1,10 @@ import React from "react"; -import L from "leaflet"; -import iconBlue from "leaflet/dist/images/marker-icon.png"; -import iconRed from "../assets/leaflet/marker-icon-red.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; +import { + BlackMapIcon, + BlueMapIcon, + RedMapIcon, +} from './MapIcons'; -const blueIcon = L.icon({ - iconUrl: iconBlue, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - -const redIcon = L.icon({ - iconUrl: iconRed, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); export const WellMapLegend: React.FC = () => { return ( @@ -39,20 +23,28 @@ export const WellMapLegend: React.FC = () => { >
Well Well
-
+
Chloride Monitored Well Chloride Monitored Well
+
+ Chloride Monitored Well + Plugged Well +
); }; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 386f9afa..dcb5df95 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,5 +1,6 @@ export * from './BackgroundBox' export * from './TristateToggle' +export * from './TopbarUserButton' export * from './ChipSelect' export * from './MergeWellModal' export * from './RegionMeasurementModals' @@ -7,9 +8,13 @@ export * from './UserSelection' export * from './CustomCardHeader' export * from './MeterRegisterSelect' export * from './RHControlled' +export * from './ReportsNavItem' +export * from './RoleChip' +export * from './IsTrueChip' export * from './DirectionCard' export * from './MeterSelection' export * from './StatCell' +export * from './StyledToggleButton' export * from './WellSelection' export * from './MeterTypeSelect' export * from './TabPanel' @@ -19,3 +24,6 @@ export * from './GridFooterWithButton' export * from './NavLink' export * from './Topbar' export * from './WellMapLegend' +export * from './ImagePreviewGrid' +export * from './ImageUploadWithPreview' +export * from './ImageDialog' diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index bf8eaaba..fdc28cd7 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,3 +1,49 @@ +import { + Home as HomeIcon, + FormatListBulletedOutlined, + ScreenshotMonitor, + Construction, + MonitorHeart, + Plumbing, + Build, + Science, + People, +} from "@mui/icons-material"; +import { SvgIconProps } from "@mui/material"; +import { ComponentType } from "react"; + +type NavItem = { + path: string; + label: string; + icon: ComponentType; + role?: "Technician" | "Admin"; // restrict by role + parent?: string; // e.g. "reports" + badge?: () => number | undefined; // function for live counts +}; + +export const navConfig: NavItem[] = [ + { path: "/", label: "Home", icon: HomeIcon }, + { path: "/chlorides", label: "Chlorides", icon: Science }, + { path: "/monitoringwells", label: "Monitoring Wells", icon: MonitorHeart }, + + // Technician + { path: "/workorders", label: "Work Orders", icon: FormatListBulletedOutlined, role: "Technician" }, + { path: "/activities", label: "Activities", icon: Construction, role: "Technician" }, + { path: "/manage/meters", label: "Manage Meters", icon: ScreenshotMonitor, role: "Technician" }, + { path: "/manage/wells", label: "Manage Wells", icon: Plumbing, role: "Technician" }, + + // Reports + { path: "/reports/monitoringwells", label: "Monitoring Wells", icon: MonitorHeart, role: "Technician", parent: "reports" }, + { path: "/reports/maintenance", label: "Maintenance", icon: Construction, role: "Technician", parent: "reports" }, + { path: "/reports/partsused", label: "Parts Used", icon: Build, role: "Technician", parent: "reports" }, + { path: "/reports/chlorides", label: "Chlorides", icon: Science, role: "Technician", parent: "reports" }, + + + // Admin + { path: "/manage/parts", label: "Manage Parts", icon: Build, role: "Admin" }, + { path: "/manage/users", label: "Manage Users", icon: People, role: "Admin" }, +]; + export const PM_COLORS: { [key: string]: string } = { "2020/2021": "brown", "2021/2022": "green", diff --git a/frontend/src/enums/WellStatus.ts b/frontend/src/enums/WellStatus.ts new file mode 100644 index 00000000..f0a15510 --- /dev/null +++ b/frontend/src/enums/WellStatus.ts @@ -0,0 +1,7 @@ +export enum WellStatus { + ACTIVE = 1, + INACTIVE = 2, + COLLAPSED = 3, + PLUGGED = 4, + UNKNOWN = 5, +} diff --git a/frontend/src/enums/index.ts b/frontend/src/enums/index.ts index 78afddef..7e3304c0 100644 --- a/frontend/src/enums/index.ts +++ b/frontend/src/enums/index.ts @@ -5,6 +5,7 @@ export * from "./MeterHistoryType"; export * from "./MeterSortByField"; export * from "./MeterStatusNames"; export * from "./SortDirection"; +export * from "./WellStatus"; export * from "./WellSortByField"; export * from "./WorkingOnArrivalValue"; export * from "./WorkOrderStatus"; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 1d6dc912..763dbad2 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./useFetchWithAuth"; export * from "./useFetchST2"; +export * from "./useIsActiveRoute"; diff --git a/frontend/src/hooks/useIsActiveRoute.ts b/frontend/src/hooks/useIsActiveRoute.ts new file mode 100644 index 00000000..20d5965f --- /dev/null +++ b/frontend/src/hooks/useIsActiveRoute.ts @@ -0,0 +1,16 @@ +import { useLocation } from "react-router-dom"; + +type RouteLike = string | { pathname?: string }; + +export function useIsActiveRoute(route: RouteLike): boolean { + const location = useLocation(); + const currentPath = location.pathname; + + // normalize target path (strip query & hash) + const targetPath = + typeof route === "string" + ? route.split("?")[0].split("#")[0] + : route.pathname ?? ""; + + return currentPath === targetPath; +} diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index 69224b1f..de06ea97 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -73,6 +73,7 @@ export interface ActivityFormControl { working_on_arrival_slug: string, selected_note_ids: number[] | null }, + photos?: File[], part_used_ids?: [] } @@ -354,6 +355,7 @@ export interface MeterHistoryDTO { history_item: any location: Location well: Well | null + photos: any } export interface MeterType { @@ -468,6 +470,7 @@ export interface Meter { contact_name?: string contact_phone?: string notes?: string + price?: number meter_type_id: number status_id?: number @@ -601,11 +604,11 @@ export interface User { id: number username?: string full_name: string + display_name?: string email?: scope_string disabled: boolean user_role_id?: number user_role?: UserRole - password?: string } diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index b1af9629..7a96bc04 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -2,7 +2,6 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient, UseQueryOption import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { - ActivityForm, ActivityTypeLU, MeterListDTO, MeterListQueryParams, @@ -876,43 +875,6 @@ export function useUpdateUserPassword(onSuccess: Function) { }); } -export function useCreateActivity(onSuccess: Function) { - const { enqueueSnackbar } = useSnackbar(); - const route = "activities"; - const authHeader = useAuthHeader(); - - return useMutation({ - mutationFn: async (activityForm: ActivityForm) => { - const response = await POSTFetch(route, activityForm, authHeader()); - - // This responsibility will eventually move to callsite when special error codes arent relied on - if (!response.ok) { - if (response.status == 422) { - enqueueSnackbar("One or More Required Fields Not Entered!", { - variant: "error", - }); - throw Error("Incomplete form, check network logs for details"); - } - if (response.status == 409) { - //There could be a couple reasons for this... out of order activity or duplicate activity - let errorText = await response.text(); - enqueueSnackbar(JSON.parse(errorText).detail, { variant: "error" }); - throw Error(errorText); - } else { - enqueueSnackbar("Unknown Error Occurred!", { variant: "error" }); - throw Error("Unknown Error: " + response.status); - } - } else { - onSuccess(); - - const responseJson = await response.json(); - return responseJson; - } - }, - retry: 0, - }); -} - export function useUpdateMeterType(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "meter_types"; diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index dd73a22c..af659122 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,24 +1,27 @@ import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/material"; +import { + Box, + Collapse, + Drawer, + Grid, + IconButton, + List, + ListSubheader, + Toolbar, + Typography +} from "@mui/material"; import { useNavigate } from "react-router-dom"; +import { ChevronLeft } from "@mui/icons-material"; +import { + NavLink, + ReportsNavItem, + RoleChip +} from "./components"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { SecurityScope, WorkOrder } from "./interfaces"; -import { - Assessment, - Build, - ChevronLeft, - Construction, - FormatListBulletedOutlined, - Home, - MonitorHeart, - People, - Plumbing, - Science, - ScreenshotMonitor, -} from "@mui/icons-material"; -import { NavLink } from "./components/NavLink"; +import { navConfig } from "./constants"; export default function Sidenav({ open, @@ -29,6 +32,7 @@ export default function Sidenav({ drawerWidth: number; onClose: () => void; }) { + const [openReportsMenu, setOpenReportsMenu] = useState(true); const navigate = useNavigate(); const authUser = useAuthUser(); @@ -41,27 +45,21 @@ export default function Sidenav({ const hasReadScope = scopes.has("read"); const hasAdminScope = scopes.has("admin"); - const userID = authUser()?.id; - - const [workOrderLabel, setWorkOrderLabel] = useState("Work Orders"); - const workOrderList = useGetWorkOrders([WorkOrderStatus.Open], { + const userId = authUser()?.id; + const [workOrderCount, setWorkOrderCount] = useState(0); + const openWorkOrdersQuery = useGetWorkOrders([WorkOrderStatus.Open], { refetchInterval: 45_000, refetchIntervalInBackground: true, enabled: hasReadScope && !!authUser() }); useEffect(() => { - if (workOrderList.data && userID) { - const userWorkOrders = workOrderList.data.filter( - (workOrder: WorkOrder) => workOrder.assigned_user_id === userID - ); - setWorkOrderLabel( - userWorkOrders.length > 0 - ? `Work Orders (${userWorkOrders.length})` - : "Work Orders" - ); + if (openWorkOrdersQuery.data && userId) { + setWorkOrderCount(openWorkOrdersQuery.data.filter( + (workOrder: WorkOrder) => workOrder.assigned_user_id === userId + )?.length ?? 0); } - }, [workOrderList.data, userID]); + }, [openWorkOrdersQuery.data, userId]); return ( - -
Pages
-
- - - - {hasReadScope && ( - <> - - - - - - - - )} - - {hasAdminScope && ( - <> - -
Admin Management
-
- - - - - )} + + Pages + + }> + {navConfig + .filter(item => !item.role) + .map(item => ( + + ))} + {hasReadScope && ( + <> + + Pages + + {navConfig + .filter(item => item.role === "Technician" && !item.parent) + .map(item => ( + + ))} + + + + {navConfig + .filter(item => item.parent === "reports") + .map(item => ( + + ))} + + + + )} + {hasAdminScope && ( + <> + + Pages + + {navConfig + .filter(item => item.role === "Admin") + .map(item => ( + + ))} + + )} +
); diff --git a/frontend/src/utils/GetRoleColor.ts b/frontend/src/utils/GetRoleColor.ts new file mode 100644 index 00000000..82f7f2fc --- /dev/null +++ b/frontend/src/utils/GetRoleColor.ts @@ -0,0 +1,12 @@ +import { ButtonProps } from "@mui/material"; + +export const getRoleColor = (role?: string): ButtonProps['color'] => { + switch (role) { + case "Admin": + return "primary"; + case "Technician": + return "secondary"; + default: + return "warning"; + } +}; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 2158b20b..48b13c8a 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,5 +1,6 @@ export * from "./DateUtils" export * from "./HttpUtils" export * from "./GetMeterMarkerColor" +export * from "./GetRoleColor" export * from "./MonitoredWellsUtils" export * from "./NumberDataFormatter" diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index c538847f..e7b41bce 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -4,11 +4,6 @@ import { Construction } from "@mui/icons-material"; import { BackgroundBox } from "../../components/BackgroundBox"; import { CustomCardHeader } from "../../components/CustomCardHeader"; -export const gridBreakpoints = { xs: 12 }; -export const toggleStyle = { - "&.Mui-selected": { borderColor: "blue", border: 1 }, -}; - export const ActivitiesView = () => { return ( diff --git a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts index 4449bd4f..faef3499 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts +++ b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts @@ -6,193 +6,204 @@ import dayjs from "dayjs" // Form validation, these are applied to the current form when submitting export const ActivityResolverSchema: Yup.ObjectSchema = Yup.object().shape({ - activity_details: Yup.object().shape({ - selected_meter: Yup.object().shape({ - id: Yup.number().required(), - }).required("Please Select A Meter"), - - activity_type: Yup.object().shape({ - id: Yup.number().required("Please Select An Activity"), - }).required("Please Select An Activity"), - - user: Yup.object().shape({ - id: Yup.number().required("Please Select A User"), - }).required("Please Select a User"), - - date: Yup.date().required('Please Select a Date'), - start_time: Yup.date().required('Please Select a Start Time'), - end_time: Yup.date().required('Please Select an End Time') - - }).required(), - - current_installation: Yup.object().when('activity_details.activity_type.id', { - is: 1, - then: (schema) => schema.shape({ - meter: Yup.object().shape({ - id: Yup.number(), - }), - well: Yup.object().shape({ - id: Yup.number().required('Please select a well.'), - }).required('Please select a well.'), - }), - otherwise: (schema) => schema.shape({ - meter: Yup.object().shape({ - id: Yup.number(), - }), - well: Yup.object().shape({ - id: Yup.number().notRequired(), - }).notRequired(), - }) + activity_details: Yup.object().shape({ + selected_meter: Yup.object().shape({ + id: Yup.number().required(), + }).required("Please Select A Meter"), + + activity_type: Yup.object().shape({ + id: Yup.number().required("Please Select An Activity"), + }).required("Please Select An Activity"), + + user: Yup.object().shape({ + id: Yup.number().required("Please Select A User"), + }).required("Please Select a User"), + + date: Yup.date().required('Please Select a Date'), + start_time: Yup.date().required('Please Select a Start Time'), + end_time: Yup.date().required('Please Select an End Time') + + }).required(), + + current_installation: Yup.object().when('activity_details.activity_type.id', { + is: 1, + then: (schema) => schema.shape({ + meter: Yup.object().shape({ + id: Yup.number(), + }), + well: Yup.object().shape({ + id: Yup.number().required('Please select a well.'), + }).required('Please select a well.'), }), + otherwise: (schema) => schema.shape({ + meter: Yup.object().shape({ + id: Yup.number(), + }), + well: Yup.object().shape({ + id: Yup.number().notRequired(), + }).notRequired(), + }) + }), - observations: Yup.array().of(Yup.object().shape({ - time: Yup.date().required(), - reading: Yup.number().typeError('Please enter a number.').min(0, 'Please enter a non-negative value.').required('Please enter a value.'), - property_type: Yup.object().shape({ - id: Yup.number().required('Please select a property type.'), - }).required('Please select a property type.'), - unit: Yup.object().shape({ - id: Yup.number().required('Please select a unit.'), - }).required('Please select a unit.') + observations: Yup.array().of(Yup.object().shape({ + time: Yup.date().required(), + reading: Yup.number().typeError('Please enter a number.').min(0, 'Please enter a non-negative value.').required('Please enter a value.'), + property_type: Yup.object().shape({ + id: Yup.number().required('Please select a property type.'), + }).required('Please select a property type.'), + unit: Yup.object().shape({ + id: Yup.number().required('Please select a unit.'), + }).required('Please select a unit.') - })).required() + })).required() }).required() // Convert the form control to the format expected by the backend export function toSubmissionForm(activityFormControl: ActivityFormControl) { - var observationForms: ObservationForm[] = [] - - activityFormControl.observations.forEach((observation: any) => { - observationForms.push({ - time: observation.time, - reading: observation.reading, - property_type_id: observation.property_type.id, - unit_id: observation.unit.id - }) + const formData = new FormData(); + var observationForms: ObservationForm[] = [] + + activityFormControl.observations.forEach((observation: any) => { + observationForms.push({ + time: observation.time, + reading: observation.reading, + property_type_id: observation.property_type.id, + unit_id: observation.unit.id }) - - const activityForm: ActivityForm = { - activity_details: { - meter_id: activityFormControl?.activity_details?.selected_meter?.id, - activity_type_id: activityFormControl?.activity_details?.activity_type?.id, - user_id: activityFormControl?.activity_details?.user?.id, - date: activityFormControl?.activity_details?.date, - start_time: activityFormControl?.activity_details?.start_time, - end_time: activityFormControl?.activity_details?.end_time, - share_ose: activityFormControl?.activity_details?.share_ose, - work_order_id: activityFormControl?.activity_details?.work_order_id == null ? undefined : activityFormControl?.activity_details?.work_order_id - }, - current_installation: { - contact_name: activityFormControl?.current_installation?.meter?.contact_name as string, - contact_phone: activityFormControl?.current_installation?.meter?.contact_phone as string, - well_id: activityFormControl?.current_installation?.well?.id, - notes: activityFormControl?.current_installation?.meter?.notes as string, - water_users: activityFormControl?.current_installation?.meter?.water_users as string, - meter_owner: activityFormControl?.current_installation?.meter?.meter_owner as string - }, - observations: observationForms, - maintenance_repair: { - service_type_ids: activityFormControl.maintenance_repair?.service_type_ids ?? [], - description: activityFormControl.maintenance_repair?.description ?? '' - }, - notes: { - working_on_arrival_slug: activityFormControl.notes.working_on_arrival_slug, - selected_note_ids: activityFormControl.notes.selected_note_ids ?? [] - }, - part_used_ids: activityFormControl.part_used_ids ?? [] - } - - return activityForm + }) + + const activityForm: ActivityForm = { + activity_details: { + meter_id: activityFormControl?.activity_details?.selected_meter?.id, + activity_type_id: activityFormControl?.activity_details?.activity_type?.id, + user_id: activityFormControl?.activity_details?.user?.id, + date: activityFormControl?.activity_details?.date, + start_time: activityFormControl?.activity_details?.start_time, + end_time: activityFormControl?.activity_details?.end_time, + share_ose: activityFormControl?.activity_details?.share_ose, + work_order_id: activityFormControl?.activity_details?.work_order_id == null ? undefined : activityFormControl?.activity_details?.work_order_id + }, + current_installation: { + contact_name: activityFormControl?.current_installation?.meter?.contact_name as string, + contact_phone: activityFormControl?.current_installation?.meter?.contact_phone as string, + well_id: activityFormControl?.current_installation?.well?.id, + notes: activityFormControl?.current_installation?.meter?.notes as string, + water_users: activityFormControl?.current_installation?.meter?.water_users as string, + meter_owner: activityFormControl?.current_installation?.meter?.meter_owner as string + }, + observations: observationForms, + maintenance_repair: { + service_type_ids: activityFormControl.maintenance_repair?.service_type_ids ?? [], + description: activityFormControl.maintenance_repair?.description ?? '' + }, + notes: { + working_on_arrival_slug: activityFormControl.notes.working_on_arrival_slug, + selected_note_ids: activityFormControl.notes.selected_note_ids ?? [] + }, + part_used_ids: activityFormControl.part_used_ids ?? [] + } + + formData.append("activity", JSON.stringify(activityForm)); + + activityFormControl.photos?.forEach((file: File) => { + formData.append("photos", file); + }); + + return formData; } // Provides the default values of the activity form export function getDefaultForm(initialMeter: Partial | null, initialWorkOrderID: number | null = null): ActivityFormControl { - //Generate start and end times using current time and end time 15min later - const start_time = Dayjs() - const end_time = Dayjs().add(15, 'minute') - - const defaultForm: ActivityFormControl = { - activity_details: { - selected_meter: initialMeter, - activity_type: null, - user: null, - date: Dayjs(), - start_time: start_time, - end_time: end_time, - share_ose: initialWorkOrderID ? true : false, - work_order_id: initialWorkOrderID - }, - - current_installation: { - meter: null, - well: null - }, - - // These should come from DB - observations: [ + //Generate start and end times using current time and end time 15min later + const start_time = Dayjs() + const end_time = Dayjs().add(15, 'minute') + + const defaultForm: ActivityFormControl = { + activity_details: { + selected_meter: initialMeter, + activity_type: null, + user: null, + date: Dayjs(), + start_time: start_time, + end_time: end_time, + share_ose: initialWorkOrderID ? true : false, + work_order_id: initialWorkOrderID + }, + + current_installation: { + meter: null, + well: null + }, + + // These should come from DB + observations: [ + { + time: dayjs.utc(), + reading: '', + property_type: { + id: 1, + units: [ { - time: dayjs.utc(), - reading: '', - property_type: { - id: 1, - units: [ - { - id: 1, name: 'Acre-feet', name_short: '...', description: '...' - }, - { - id: 2, name: 'Gallons', name_short: '...', description: '...' - } - ]}, - unit: {id: 3} + id: 1, name: 'Acre-feet', name_short: '...', description: '...' }, { - time: dayjs.utc(), - reading: '', - property_type: { - id: 2, - units: [ - { - id: 3, name: 'Kilowatt hours', name_short: '...', description: '...' - }, - { - id: 4, name: 'Gas BTU', name_short: '...', description: '...' - } - ]}, - unit: {id: 3} - }, + id: 2, name: 'Gallons', name_short: '...', description: '...' + } + ] + }, + unit: { id: 3 } + }, + { + time: dayjs.utc(), + reading: '', + property_type: { + id: 2, + units: [ { - time: dayjs.utc(), - reading: '', - property_type: { - id: 7, - units: [ - { - id: 11, name: 'Inches', name_short: '...', description: '...' - } - ]}, - unit: {id: 7} + id: 3, name: 'Kilowatt hours', name_short: '...', description: '...' }, { - time: dayjs.utc(), - reading: '', - property_type: { - id: 3, - units: [ - { - id: 5, name: 'Percent', name_short: '...', description: '...' - } - ]}, - unit: {id: 5} + id: 4, name: 'Gas BTU', name_short: '...', description: '...' + } + ] + }, + unit: { id: 3 } + }, + { + time: dayjs.utc(), + reading: '', + property_type: { + id: 7, + units: [ + { + id: 11, name: 'Inches', name_short: '...', description: '...' } - - ], - notes: { - working_on_arrival_slug: 'not-checked', - selected_note_ids: [] - } + ] + }, + unit: { id: 7 } + }, + { + time: dayjs.utc(), + reading: '', + property_type: { + id: 3, + units: [ + { + id: 5, name: 'Percent', name_short: '...', description: '...' + } + ] + }, + unit: { id: 5 } + } + + ], + notes: { + working_on_arrival_slug: 'not-checked', + selected_note_ids: [] } + } - return defaultForm + return defaultForm } diff --git a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx index 62978404..d8a7dfff 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx @@ -1,8 +1,7 @@ -import { Box, Grid } from "@mui/material"; -import ToggleButton from "@mui/material/ToggleButton"; +import { Box, Grid, Typography } from "@mui/material"; import { useFieldArray } from "react-hook-form"; import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { gridBreakpoints, toggleStyle } from "../ActivitiesView"; +import { StyledToggleButton } from "../../../components"; import { useGetServiceTypes } from "../../../service/ApiServiceNew"; export default function MaintenanceRepairSelection({ @@ -30,25 +29,24 @@ export default function MaintenanceRepairSelection({ const selectItem = (ID: number) => append(ID); const MaintanenceToggleButton = ({ item }: any) => ( - - + { isSelected(item.id) ? unselectItem(item.id) : selectItem(item.id); }} - sx={toggleStyle} > {item.service_name} - + ); return ( -

Maintanence/Repair

+ + Maintanence/Repair + {serviceTypes.isLoading ? ( @@ -56,14 +54,14 @@ export default function MaintenanceRepairSelection({ ) : ( - + {serviceTypes.data?.map((item: any) => { return ; })} )} - + (); @@ -37,12 +37,58 @@ export default function MeterActivityEntry() { const [isMeterAndActivitySelected, setIsMeterAndActivitySelected] = useState(false); - function onSuccessfulSubmit() { + function onSuccessfulSubmit(activity_id: number, meter_id: number) { enqueueSnackbar("Successfully Submitted Activity!", { variant: "success" }); - navigate("/meters"); + navigate({ + pathname: "/manage/meters", + search: `?meter_id=${meter_id}&activity_id=${activity_id}`, + }); } - const createActivity = useCreateActivity(onSuccessfulSubmit); + const createActivity = useMutation({ + mutationFn: async (activityForm: FormData) => { + const response = await fetch(`${API_URL}/activities`, { + method: "POST", + headers: { + Authorization: authHeader(), + }, + body: activityForm, + }); + + if (!response.ok) { + if (response.status == 422) { + enqueueSnackbar("One or More Required Fields Not Entered!", { + variant: "error", + }); + throw Error("Incomplete form, check network logs for details"); + } + if (response.status == 409) { + let errorText = await response.text(); + enqueueSnackbar(JSON.parse(errorText).detail, { variant: "error" }); + throw Error(errorText); + } else { + enqueueSnackbar("Unknown Error Occurred!", { variant: "error" }); + throw Error("Unknown Error: " + response.status); + } + } + return response.json(); + }, + retry: 0, + onMutate: () => { + enqueueSnackbar("Submitting activity...", { variant: "info" }); + }, + onError: (error: any) => { + enqueueSnackbar(error.message || "Submission failed", { + variant: "error", + }); + }, + onSuccess: (responseJson) => { + const activity_id = responseJson.id; + const meter_id = responseJson.meter_id; + enqueueSnackbar("Successfully Submitted Activity!", { variant: "success" }); + onSuccessfulSubmit(activity_id, meter_id); + }, + }); let initialMeter: Partial | null = null; const qpMeterID = searchParams.get("meter_id"); @@ -74,15 +120,14 @@ export default function MeterActivityEntry() { createActivity.mutate(toSubmissionForm(data)); useEffect(() => { - console.log(meterDetails.data); - console.log(watch("activity_details.activity_type")?.name); setHasMeterActivityConflict( - (meterDetails.data?.status.status_name == "Installed" && - watch("activity_details.activity_type")?.name == - ActivityType.Install) || - (meterDetails.data?.status.status_name != "Installed" && - watch("activity_details.activity_type")?.name == - ActivityType.Uninstall), + ( + meterDetails.data?.status.status_name == "Installed" && + watch("activity_details.activity_type")?.name == ActivityType.Install + ) || ( + meterDetails.data?.status.status_name != "Installed" && + watch("activity_details.activity_type")?.name == ActivityType.Uninstall + ), ); }, [meterDetails.data, watch("activity_details.activity_type")?.name]); @@ -117,7 +162,6 @@ export default function MeterActivityEntry() { return ( - {!hasMeterActivityConflict && isMeterAndActivitySelected ? ( @@ -125,7 +169,6 @@ export default function MeterActivityEntry() { - {hasErrors(errors) ? ( @@ -133,6 +176,7 @@ export default function MeterActivityEntry() { ) : ( + + + +
+ ); +} diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index b57e4621..9ece1568 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - Chip, Grid, InputAdornment, Stack, @@ -18,7 +17,7 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import { MeterTypeLU } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { IsTrueChip, CustomCardHeader } from "../../components"; export const MeterTypesTable = ({ setSelectedMeterType, @@ -45,12 +44,7 @@ export const MeterTypesTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => - params.value == true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, ]; diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index cd56e54e..e403a0b1 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -9,6 +9,7 @@ import { Chip, FormControl, Grid, + InputAdornment, InputLabel, MenuItem, OutlinedInput, @@ -185,6 +186,9 @@ export const PartDetailsCard = ({ label="Price" type="number" inputProps={{ step: "0.01" }} + InputProps={{ + startAdornment: $, + }} /> diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 1ed7a630..8d4f74bc 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - Chip, Grid, InputAdornment, Stack, @@ -19,6 +18,7 @@ import { Part } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { IsTrueChip } from "../../components"; export const PartsTable = ({ setSelectedPartID, @@ -46,22 +46,12 @@ export const PartsTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => - params.value == true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, { field: "commonly_used", headerName: "Commonly Used", - renderCell: (params: any) => - params.value == true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, ]; diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 156bc0ea..df061442 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -29,24 +29,14 @@ import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer, Sa import { useFetchWithAuth } from "../../../hooks"; import { useGetWellLocations } from "../../../service/ApiServiceNew"; import { Well } from "../../../interfaces"; - -import iconRed from "../../../assets/leaflet/marker-icon-red.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; +import { RedMapIcon, BlackMapIcon } from "../../../components/MapIcons"; +import { WellStatus } from "../../../enums"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "leaflet/dist/leaflet.css"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; -const redIcon = L.icon({ - iconUrl: iconRed, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), to: yup @@ -64,17 +54,19 @@ const defaultSchema = { to: dayjs(), }; -interface iMinMaxAvg { +interface iMinMaxAvgMedCount { min?: number; max?: number; avg?: number; + median?: number; + count?: number; } interface iChlorideReportNums { - north: iMinMaxAvg; - south: iMinMaxAvg; - east: iMinMaxAvg; - west: iMinMaxAvg; + north: iMinMaxAvgMedCount; + south: iMinMaxAvgMedCount; + east: iMinMaxAvgMedCount; + west: iMinMaxAvgMedCount; } export const ChloridesReportView = () => { @@ -266,6 +258,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.north?.min} avg={chloridesQuery.data?.north?.avg} max={chloridesQuery.data?.north?.max} + median={chloridesQuery.data?.north?.median} + count={chloridesQuery.data?.north?.count} /> @@ -274,6 +268,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.south?.min} avg={chloridesQuery.data?.south?.avg} max={chloridesQuery.data?.south?.max} + median={chloridesQuery.data?.south?.median} + count={chloridesQuery.data?.south?.count} /> @@ -282,6 +278,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.east?.min} avg={chloridesQuery.data?.east?.avg} max={chloridesQuery.data?.east?.max} + median={chloridesQuery.data?.east?.median} + count={chloridesQuery.data?.east?.count} /> @@ -290,6 +288,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.west?.min} avg={chloridesQuery.data?.west?.avg} max={chloridesQuery.data?.west?.max} + median={chloridesQuery.data?.west?.median} + count={chloridesQuery.data?.west?.count} /> @@ -349,7 +349,9 @@ export const ChloridesReportView = () => { well.location?.latitude, well.location?.longitude, ]} - icon={redIcon} + icon={ + well.well_status_id === WellStatus.PLUGGED ? BlackMapIcon : RedMapIcon + } > {well.name || well.ra_number || well.id} diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index c7b8c69c..a479c2e2 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -26,19 +26,19 @@ export const ReportsView = () => { /> */} {/* { diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 253fe3c6..e56aeaf0 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -1,4 +1,5 @@ import * as yup from 'yup'; +import { enqueueSnackbar } from "notistack"; import { yupResolver } from "@hookform/resolvers/yup"; import { useForm, Controller } from "react-hook-form"; import { @@ -7,126 +8,215 @@ import { Divider, Typography, Box, - // Button, MenuItem, TextField, Grid, - Alert, ListItemIcon, Chip, + Accordion, + AccordionSummary, + AccordionDetails, + Button, + ListSubheader, + Skeleton, + IconButton, + Stack, } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; -import { useAuthUser } from "react-auth-kit"; -import { useEffect, useMemo, useState } from "react"; -import HomeIcon from "@mui/icons-material/Home"; +import { useAuthUser, useSignIn } from "react-auth-kit"; import { - Build, - FormatListBulletedOutlined, - ScreenshotMonitor, - Construction, - MonitorHeart, - Plumbing, - Assessment, - Science + Check, + Close, + Edit, + ExpandMore } from '@mui/icons-material'; -import { BackgroundBox, CustomCardHeader } from "../components"; +import { BackgroundBox, CustomCardHeader, ImageUploadWithPreview, IsTrueChip, RoleChip } from "../components"; +import { navConfig } from '../constants'; +import { useFetchWithAuth } from '../hooks'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { SecurityScope } from '../interfaces'; +import { useEffect, useState } from 'react'; -const redirectOptions = [ - { value: "/", label: "Home", icon: }, - { value: "/workorders", label: "Work Orders", icon: }, - { value: "/meters", label: "Meter Information", icon: }, - { value: "/activities", label: "Activities", icon: }, - { value: "/wells", label: "Monitoring Wells", icon: }, - { value: "/wellmanagement", label: "Manage Wells", icon: }, - { value: "/reports", label: "Reports", icon: }, - { value: "/reports/wells", label: "Monitoring Wells Report", icon: }, - { value: "/reports/maintenance", label: "Maintenance Report", icon: }, - { value: "/reports/partsused", label: "Parts Used Report", icon: }, - { value: "/reports/chlorides", label: "Chlorides Report", icon: }, -]; +const redirectOptions = { + public: navConfig.filter(item => !item.role), + technician: navConfig.filter(item => item.role === "Technician"), + admin: navConfig.filter(item => item.role === "Admin"), +}; + +const redirectSchema = yup.object().shape({ + redirect_page: yup.string().required("Please select a redirect page"), +}); -const schema = yup.object().shape({ - redirectPage: yup.string().optional(), - currentPassword: yup.string().optional(), - newPassword: yup.string().optional(), +const passwordSchema = yup.object().shape({ + currentPassword: yup.string().required("Current password is required"), + newPassword: yup.string().required("New password is required"), confirmPassword: yup .string() - .oneOf([yup.ref("newPassword"), ""], "Passwords must match"), + .oneOf([yup.ref("newPassword")], "Passwords must match") + .required("Please confirm new password"), }); -const FALLBACK_REDIRECT = "/"; +export const Settings = () => { + const authUser = useAuthUser(); + const user = authUser(); + const signIn = useSignIn(); + const fetchWithAuth = useFetchWithAuth(); + const scopes: Set = new Set( + authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string + ) ?? [] + ); -const RoleChip = ({ role }: { role: string }) => { - switch (role) { - case "Admin": { - return ; - } - case "Technician": { - return ; - } - default: { - return ; - } + const hasReadScope = scopes.has("read"); + const hasAdminScope = scopes.has("admin"); + + const [isEditing, setIsEditing] = useState(false); + + const { + control: displayNameControl, + handleSubmit: displayNameHandleSubmit, + reset: displayNameReset, + } = useForm<{ display_name: string }>({ + defaultValues: { display_name: user?.display_name ?? "" }, + }); + + const displayNameMutation = useMutation({ + mutationFn: async (data: { display_name: string }) => { + return await fetchWithAuth({ + method: "POST", + route: "/settings/display_name", + body: data, + }); + }, + onSuccess: (responseJson: any) => { + enqueueSnackbar("Display name updated successfully.", { variant: "success" }); + + // Grab the current auth state & update it + if (user) { + signIn({ + token: localStorage.getItem("_auth")!, // reuse current token + expiresIn: 300, // reuse the expiry window you want + tokenType: "bearer", + authState: { + ...user, + display_name: responseJson.display_name, // overwrite just this field + }, + }); + } + }, + onError: () => { + enqueueSnackbar("Failed to update display name.", { variant: "error" }); + }, + }); + + const onDisplayNameSubmit = ({ display_name }: { display_name: string }) => { + displayNameMutation.mutate({ display_name }) } -} -const IsActiveChip = ({ active }: { active: boolean }) => { - return active ? ( - - ) : ( - - ); -} + const queryClient = useQueryClient(); + const getRedirectPageQuery = useQuery({ + queryKey: ["redirectPage"], + queryFn: async () => fetchWithAuth({ + method: "GET", + route: "/settings/redirect_page", + }), + }); -export const Settings = () => { - const authUser = useAuthUser(); - const [savedMessage, setSavedMessage] = useState(""); + const redirectMutation = useMutation({ + mutationFn: async (data: { redirect_page: string }) => { + return await fetchWithAuth({ + method: "POST", + route: "/settings/redirect_page", + body: data, + }); + }, + onSuccess: (responseJson: { message: string, redirect_page: string }) => { + enqueueSnackbar("Redirect page updated successfully.", { variant: "success" }); + queryClient.invalidateQueries(["redirectPage"]); - // always read the latest from localStorage - const defaultValues = useMemo(() => { - const stored = localStorage.getItem("redirectPage"); - return { - redirectPage: stored ?? FALLBACK_REDIRECT, - currentPassword: "", - newPassword: "", - confirmPassword: "", - }; - }, []); + // Grab the current auth state & update it + if (user) { + signIn({ + token: localStorage.getItem("_auth")!, // reuse current token + expiresIn: 300, // reuse the expiry window you want + tokenType: "bearer", + authState: { + ...user, + redirect_page: responseJson.redirect_page, // overwrite just this field + }, + }); + } + }, + onError: () => { + enqueueSnackbar("Failed to update redirect page.", { variant: "error" }); + }, + }); const { - control, - handleSubmit, - watch, - // formState: { errors, isValid }, + control: redirectControl, + handleSubmit: handleRedirectSubmit, + reset: redirectReset } = useForm({ - resolver: yupResolver(schema), - mode: "onChange", - defaultValues, + resolver: yupResolver(redirectSchema), + defaultValues: { redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/" }, + values: { redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/" }, // react-hook-form v7 pattern for sync }); - // Auto-save redirectPage when it changes - const redirectPage = watch("redirectPage"); useEffect(() => { - if (redirectPage) { - localStorage.setItem("redirectPage", redirectPage); - setSavedMessage("Redirect preference saved locally (not synced across devices)."); + if (getRedirectPageQuery.data?.redirect_page) { + redirectReset({ redirect_page: getRedirectPageQuery.data.redirect_page }); } - }, [redirectPage]); + }, [getRedirectPageQuery.data, redirectReset]); - const onSubmit = (data: any) => { - if (data.newPassword && data.currentPassword) { - // password reset API call would go here - console.log("Password reset request:", data); - setSavedMessage("Password reset request submitted."); - } + const onRedirectSubmit = (data: any) => { + redirectMutation.mutate(data); }; - const user = authUser(); + const passwordMutation = useMutation({ + mutationFn: async (data: { + currentPassword: string; + newPassword: string; + }) => { + const res = await fetch("/settings/password_reset", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("_auth")}`, + }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Password reset failed"); + return await res.json(); + }, + onSuccess: () => { + enqueueSnackbar("Password reset request submitted.", { variant: "success" }); + }, + }); + + const { + control: passwordControl, + handleSubmit: handlePasswordSubmit, + formState: { errors: passwordErrors }, + } = useForm({ + resolver: yupResolver(passwordSchema), + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + }); + + const onPasswordSubmit = (data: any) => { + passwordMutation.mutate({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }); + }; return ( - + {/* User Info */} @@ -135,177 +225,289 @@ export const Settings = () => { - - - Full Name:{" "} - - {user?.full_name ?? "N/A"} - - + + Full Name: + - - - Email:{" "} - - {user?.email ?? "N/A"} - - + + Email: + - - - Username:{" "} - - {user?.username ?? "N/A"} - - + + Username: + - - Role: - + {!isEditing ? ( + <> + Display Name: + + setIsEditing(true)}> + + + + ) : ( + <> + ( + + )} + /> + + { + displayNameReset({ display_name: user?.display_name ?? "" }); + setIsEditing(false); + }} + > + + + + + + + + )} + + + Role: - - Active: - - + Active: + -
- - Preferences - - ( - - {redirectOptions.map((option) => ( - - - {option.icon} - {option.label} - - - ))} - - )} - /> - {/* - - - Password Reset - - - - ( - - )} - /> - - - ( - - )} - /> - - - ( - - )} - /> - + + Preferences + + + + + }> + Avatar Configuration + + + + + + + + + + + }> + Redirect Page After Login + + + + + + + + { + // flatten all available paths + const availablePaths = [ + ...redirectOptions.public.map(o => o.path), + ...(hasReadScope ? redirectOptions.technician.map(o => o.path) : []), + ...(hasAdminScope ? redirectOptions.admin.map(o => o.path) : []), + ]; + + // guard: if no options available yet, render empty select + if (getRedirectPageQuery.isFetching && availablePaths.length === 0) { + return ( + + ); + } + + const safeValue = availablePaths.includes(field.value) + ? field.value + : "/"; + + return ( + field.onChange(e)} + > + {redirectOptions.public.length > 0 && [ + + Pages + , + ...redirectOptions.public.map((option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + + + ); + }), + ]} + {hasReadScope && redirectOptions.technician.length > 0 && [ + + Pages + , + ...redirectOptions.technician.map((option) => { + const Icon = option.icon; + return ( + + + + + + {option.label}{option.parent === "reports" ? " Report" : null} + + + ); + }), + ]} + {hasAdminScope && redirectOptions.admin.length > 0 && [ + + Pages + , + ...redirectOptions.admin.map((option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + + + ); + }), + ]} + + ); + }} + /> + + + + + + + + + + + + }> + Password Reset + + + + +
+ + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + + + +
+
+
+
+
- - - - */} - - {savedMessage && ( - - {savedMessage} - - )} +
-
+ ); }; diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 6c94b39e..1bcf638d 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -36,28 +36,32 @@ import { import { CustomCardHeader } from "../../components/CustomCardHeader"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ - username: Yup.string().required("Please enter a username."), full_name: Yup.string().required("Please enter a full name."), + display_name: Yup.string().required("Please enter a display name."), + username: Yup.string().required("Please enter a username."), email: Yup.string().required("Please enter an email."), disabled: Yup.boolean().required("Please indicate if user is active."), user_role: Yup.object().required("Please indicate the users role."), password: Yup.string(), }); -// Format the submission as the backend schema specifies -function formatSubmission(user: User) { +const formatSubmission = (user: User) => { let formattedUser = user; formattedUser.user_role_id = user.user_role?.id; delete formattedUser.user_role; return formattedUser; } -function SetNewPasswordAccordion({ control, errorMessage, handleSubmit }: any) { +const SetNewPasswordAccordion = ({ + control, + errorMessage, + handleSubmit +}: any) => { return ( - + } - sx={{ m: 0, ml: 1, mr: 1, p: 0, color: "#595959" }} + sx={{ m: 0, mx: 2, p: 0, color: "#595959" }} > {" "}   @@ -65,7 +69,7 @@ function SetNewPasswordAccordion({ control, errorMessage, handleSubmit }: any) { - + - + @@ -87,22 +91,14 @@ function SetNewPasswordAccordion({ control, errorMessage, handleSubmit }: any) { ); } -interface UserDetailsCardProps { - selectedUser: User | undefined; - userAddMode: boolean; -} - -// Handles adding, updating and changing the password of a user -// If updating a user password, a special endpoint is called -// When updating or creating a user, the values are validated, then the submit handler is called -// Any validation not in the validation schema must be checked in the submit handler export const UserDetailsCard = ({ selectedUser, userAddMode, -}: UserDetailsCardProps) => { +}: { + selectedUser?: User; + userAddMode: boolean; +}) => { const rolesList = useGetRoles(); - - // React hook form for user field values const { handleSubmit, control, @@ -114,31 +110,24 @@ export const UserDetailsCard = ({ resolver: yupResolver(UserResolverSchema), }); - // Submission callbacks - function onSuccessfulUpdate() { + const onSuccessfulUpdate = () => enqueueSnackbar("Successfully Updated User!", { variant: "success" }); - } - function onSuccessfulPasswordUpdate() { - enqueueSnackbar("Successfully Updated User's Password!", { - variant: "success", - }); - } - function onSuccessfulCreate() { + const onSuccessfulPasswordUpdate = () => + enqueueSnackbar("Successfully Updated User's Password!", { variant: "success" }); + const onSuccessfulCreate = () => { enqueueSnackbar("Successfully Created New User!", { variant: "success" }); reset(); } + const onErr = (data: any) => console.error("ERR: ", data); const updateUser = useUpdateUser(onSuccessfulUpdate); const createUser = useCreateUser(onSuccessfulCreate); const updateUserPassword = useUpdateUserPassword(onSuccessfulPasswordUpdate); - // Submit handlers - function onSaveChanges(user: User) { - updateUser.mutate(formatSubmission(user)); - } + const onSaveChanges = (user: User) => updateUser.mutate(formatSubmission(user)); - function onCreateUser(user: User) { + const onCreateUser = (user: User) => { if (!user.password || user.password.length < 1) { enqueueSnackbar("Please provide a password.", { variant: "error" }); return; @@ -146,10 +135,10 @@ export const UserDetailsCard = ({ createUser.mutate(formatSubmission(user)); } - function onUpdateUserPassword( + const onUpdateUserPassword = ( userId: number, newPassword: string | undefined, - ) { + ) => { if (!newPassword || newPassword.length < 1) { enqueueSnackbar("Please provide a new password.", { variant: "error" }); return; @@ -161,7 +150,6 @@ export const UserDetailsCard = ({ updateUserPassword.mutate(updatedUserPassword); } - // Populate the form with the selected user's details useEffect(() => { if (selectedUser != undefined) { reset(); @@ -171,12 +159,10 @@ export const UserDetailsCard = ({ } }, [selectedUser]); - // Empty the form if entering user add mode useEffect(() => { if (userAddMode) reset(); }, [userAddMode]); - // Determine if form is valid, {errors} in useEffect or formState's isValid don't work const hasErrors = () => Object.keys(errors).length > 0; return ( @@ -186,105 +172,110 @@ export const UserDetailsCard = ({ icon={userAddMode ? AddIcon : EditIcon} /> - - - - - - - - - - - + + + + + + + + + + + + + + + (label ? "False" : "True")} + error={errors?.disabled?.message} + /> + + + role.name} + control={control} + error={errors?.user_role?.message} + /> + + + {userAddMode ? ( - - - (label ? "False" : "True")} - error={errors?.disabled?.message} + label="Password" + error={errors?.password?.message != undefined} + helperText={errors?.password?.message} /> - - - role.name} + ) : ( + + onUpdateUserPassword(watch("id"), watch("password")) + } /> - - - {/* Show 'Set New Password for User' accordion if editing a user, show regular textfield if adding user */} - - {userAddMode ? ( - - ) : ( - - onUpdateUserPassword(watch("id"), watch("password")) - } - /> - )} - - - - {hasErrors() ? ( - - Please correct any errors before submission. - - ) : userAddMode ? ( - - ) : ( - )} + + {hasErrors() ? ( + + Please correct any errors before submission. + + ) : userAddMode ? ( + + ) : ( + + )} + ); diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index 9855882c..e3a38a6b 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -14,7 +14,6 @@ export const UserManagementView = () => { const [selectedRole, setSelectedRole] = useState(); const [roleAddMode, setRoleAddMode] = useState(true); - // Exit add mode when table row is selected useEffect(() => { if (selectedUser) setUserAddMode(false); }, [selectedUser]); diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index ecc38d27..48f901ec 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - Chip, Grid, InputAdornment, TextField, @@ -17,7 +16,7 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { RoleChip, CustomCardHeader, IsTrueChip } from "../../components"; export const UsersTable = ({ setSelectedUser, @@ -34,40 +33,25 @@ export const UsersTable = ({ const cols: GridColDef[] = [ { field: "full_name", headerName: "Full Name", width: 200 }, - { field: "email", headerName: "Email", width: 250 }, - { field: "username", headerName: "Username", width: 150 }, { field: "user_role", headerName: "Role", - width: 200, + width: 125, valueGetter: (_, row) => row.user_role.name, - renderCell: (params: any) => { - switch (params.value) { - case "Admin": { - return ; - } - case "Technician": { - return ; - } - default: { - return ; - } - } - }, + renderCell: (params: any) => }, + { field: "email", headerName: "Email", width: 250 }, + { field: "username", headerName: "Username", width: 150 }, { field: "disabled", headerName: "Active", - renderCell: (params: any) => - params.value != true ? ( - - ) : ( - - ), + width: 80, + renderCell: (params: any) => }, + { field: "display_name", headerName: "Display Name", width: 150 }, + { field: "redirect_page", headerName: "Redirect Page", width: 200 }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid useEffect(() => { const psq = userSearchQuery.toLowerCase(); let filtered = (usersList.data ?? []).filter( diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index 89992253..c622f495 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -1,36 +1,15 @@ import { useEffect } from "react"; import { useDebounce } from "use-debounce"; - import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; - -import L from "leaflet"; -import iconBlue from "leaflet/dist/images/marker-icon.png"; -import iconRed from "../../assets/leaflet/marker-icon-red.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; - -const blueIcon = L.icon({ - iconUrl: iconBlue, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - -const redIcon = L.icon({ - iconUrl: iconRed, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - -import "leaflet/dist/leaflet.css"; +import { Box, Typography } from "@mui/material"; import { useGetWellLocations } from "../../service/ApiServiceNew"; import { Well } from "../../interfaces"; -import { Box, Typography } from "@mui/material"; import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, WellMapLegend } from "../../components"; +import { BlueMapIcon, RedMapIcon, BlackMapIcon } from "../../components/MapIcons"; +import { WellStatus } from "../../enums"; + +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; @@ -114,7 +93,7 @@ export default function WellSelectionMap({ eventHandlers={{ click: () => setSelectedWell(well), }} - icon={well.chloride_group_id != null ? redIcon : blueIcon} + icon={getWellIcon(well)} > {well.name || well.ra_number || well.id} @@ -169,3 +148,14 @@ export default function WellSelectionMap({ ); } + +const getWellIcon = (well: Well) => { + if (well.well_status_id === WellStatus.PLUGGED) { + return BlackMapIcon; + } + if (well.chloride_group_id != null) { + return RedMapIcon; + } + return BlueMapIcon; +} + diff --git a/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx b/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx new file mode 100644 index 00000000..a4d4155d --- /dev/null +++ b/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { + GridActionsCellItem, + GridActionsCellItemProps, +} from "@mui/x-data-grid"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; + +export function DeleteWorkOrder({ + deleteUser, + deleteMessage, + ...props +}: GridActionsCellItemProps & { + deleteUser: () => void; + deleteMessage?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} /> + setOpen(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {deleteMessage} + + + This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx new file mode 100644 index 00000000..3532820b --- /dev/null +++ b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from "@mui/material"; +import { + MeterListDTO, + NewWorkOrder, +} from "../../interfaces"; +import MeterSelection from "../../components/MeterSelection"; + +interface NewWorkOrderModalProps { + openNewWorkOrderModal: boolean; + closeNewWorkOrderModal: () => void; + submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; +} + +export function NewWorkOrderModal({ + openNewWorkOrderModal, + closeNewWorkOrderModal, + submitNewWorkOrder, +}: NewWorkOrderModalProps) { + const [workOrderTitle, setWorkOrderTitle] = useState(""); + const [workOrderMeter, setWorkOrderMeter] = useState< + MeterListDTO | undefined + >(); + const [meterSelectionError, setMeterSelectionError] = + useState(false); + const [titleError, setTitleError] = useState(false); + + function handleSubmit() { + if (!workOrderMeter) { + setMeterSelectionError(true); + return; + } + if (!workOrderTitle) { + setTitleError(true); + return; + } + + //If both fields are filled, submit the work order + //Create a new work order object + const newWorkOrder: NewWorkOrder = { + date_created: new Date(), + meter_id: workOrderMeter.id, + title: workOrderTitle, + }; + submitNewWorkOrder(newWorkOrder); + closeNewWorkOrderModal(); + + //Reset the form + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + } + + const handleCancel = () => { + closeNewWorkOrderModal(); + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + }; + + return ( + + Create a New Work Order + + + To create a new work order, please select a meter and title. Other + fields can be edited as needed after creation. + + + setWorkOrderTitle(event.target.value)} + error={titleError} + helperText={titleError ? "Title cannot be empty" : ""} + /> + + + + + + + ); +} diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 3056766b..56cf9be5 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -6,8 +6,6 @@ import { DataGrid, GridColDef, GridRowModel, - GridActionsCellItem, - GridActionsCellItemProps, GridRenderCellParams, GridRowId, GridFilterItem, @@ -20,155 +18,22 @@ import { useCreateWorkOrder, } from "../../service/ApiServiceNew"; import { WorkOrderStatus } from "../../enums"; -import MeterSelection from "../../components/MeterSelection"; import { Box, Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, IconButton, Stack, - TextField, } from "@mui/material"; import GridFooterWithButton from "../../components/GridFooterWithButton"; import { MeterActivity, - MeterListDTO, NewWorkOrder, SecurityScope, } from "../../interfaces"; import { useAuthUser } from "react-auth-kit"; import { Link, createSearchParams } from "react-router-dom"; - -function DeleteWorkOrder({ - deleteUser, - deleteMessage, - ...props -}: GridActionsCellItemProps & { - deleteUser: () => void; - deleteMessage?: string; -}) { - const [open, setOpen] = useState(false); - - return ( - <> - setOpen(true)} /> - setOpen(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - {deleteMessage} - - - This action cannot be undone. - - - - - - - - - ); -} - -interface NewWorkOrderModalProps { - openNewWorkOrderModal: boolean; - closeNewWorkOrderModal: () => void; - submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; -} - -function NewWorkOrderModal({ - openNewWorkOrderModal, - closeNewWorkOrderModal, - submitNewWorkOrder, -}: NewWorkOrderModalProps) { - const [workOrderTitle, setWorkOrderTitle] = useState(""); - const [workOrderMeter, setWorkOrderMeter] = useState< - MeterListDTO | undefined - >(); - const [meterSelectionError, setMeterSelectionError] = - useState(false); - const [titleError, setTitleError] = useState(false); - - function handleSubmit() { - if (!workOrderMeter) { - setMeterSelectionError(true); - return; - } - if (!workOrderTitle) { - setTitleError(true); - return; - } - - //If both fields are filled, submit the work order - //Create a new work order object - const newWorkOrder: NewWorkOrder = { - date_created: new Date(), - meter_id: workOrderMeter.id, - title: workOrderTitle, - }; - submitNewWorkOrder(newWorkOrder); - closeNewWorkOrderModal(); - - //Reset the form - setWorkOrderMeter(undefined); - setWorkOrderTitle(""); - } - - const handleCancel = () => { - closeNewWorkOrderModal(); - setWorkOrderMeter(undefined); - setWorkOrderTitle(""); - }; - - return ( - - Create a New Work Order - - - To create a new work order, please select a meter and title. Other - fields can be edited as needed after creation. - - - setWorkOrderTitle(event.target.value)} - error={titleError} - helperText={titleError ? "Title cannot be empty" : ""} - /> - - - - - - - ); -} +import { DeleteWorkOrder } from "./DeleteWorkOrder"; +import { NewWorkOrderModal } from "./NewWorkOrderModal"; export default function WorkOrdersTable() { const [workOrderFilters, setWorkOrderFilters] = useState([ @@ -302,7 +167,7 @@ export default function WorkOrdersTable() { return ( @@ -353,7 +218,7 @@ export default function WorkOrdersTable() { diff --git a/migrations/20250908000152_add_avatars_and_redirect_routes_to_user_table.down.sql b/migrations/20250908000152_add_avatars_and_redirect_routes_to_user_table.down.sql new file mode 100644 index 00000000..475f5ab6 --- /dev/null +++ b/migrations/20250908000152_add_avatars_and_redirect_routes_to_user_table.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public."Users" + DROP COLUMN IF EXISTS display_name, + DROP COLUMN IF EXISTS redirect_page, + DROP COLUMN IF EXISTS avatar_img; diff --git a/migrations/20250908000152_add_avatars_and_redirect_routes_to_user_table.up.sql b/migrations/20250908000152_add_avatars_and_redirect_routes_to_user_table.up.sql new file mode 100644 index 00000000..4ca6337a --- /dev/null +++ b/migrations/20250908000152_add_avatars_and_redirect_routes_to_user_table.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE public."Users" + ADD COLUMN display_name varchar NULL, + ADD COLUMN redirect_page varchar NULL DEFAULT '/', + ADD COLUMN avatar_img varchar NULL; + +-- Initialize display_name to match full_name for existing users +UPDATE public."Users" +SET display_name = full_name +WHERE display_name IS NULL; diff --git a/migrations/20250910204829_create_MeterActivityPhoto_table.down.sql b/migrations/20250910204829_create_MeterActivityPhoto_table.down.sql new file mode 100644 index 00000000..fd04c0ce --- /dev/null +++ b/migrations/20250910204829_create_MeterActivityPhoto_table.down.sql @@ -0,0 +1,5 @@ +-- Drop index first (safe cleanup) +DROP INDEX IF EXISTS idx_meter_activity_photos_activity_id; + +-- Drop the MeterActivityPhotos table +DROP TABLE IF EXISTS public."MeterActivityPhotos"; diff --git a/migrations/20250910204829_create_MeterActivityPhoto_table.up.sql b/migrations/20250910204829_create_MeterActivityPhoto_table.up.sql new file mode 100644 index 00000000..174a678a --- /dev/null +++ b/migrations/20250910204829_create_MeterActivityPhoto_table.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE public."MeterActivityPhotos" ( + id serial PRIMARY KEY, + meter_activity_id int4 NOT NULL REFERENCES public."MeterActivities"(id) ON DELETE CASCADE, + file_name varchar NOT NULL, + gcs_path varchar NOT NULL, + uploaded_at timestamptz DEFAULT now() +); + +CREATE INDEX idx_meter_activity_photos_activity_id + ON public."MeterActivityPhotos"(meter_activity_id); diff --git a/migrations/20250911190846_update_Meters_table_add_price_field.down.sql b/migrations/20250911190846_update_Meters_table_add_price_field.down.sql new file mode 100644 index 00000000..811eeb5c --- /dev/null +++ b/migrations/20250911190846_update_Meters_table_add_price_field.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public."Meters" +DROP COLUMN price; diff --git a/migrations/20250911190846_update_Meters_table_add_price_field.up.sql b/migrations/20250911190846_update_Meters_table_add_price_field.up.sql new file mode 100644 index 00000000..0923d9c2 --- /dev/null +++ b/migrations/20250911190846_update_Meters_table_add_price_field.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public."Meters" +ADD COLUMN price numeric(10, 2) NULL; diff --git a/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql new file mode 100644 index 00000000..f380a688 --- /dev/null +++ b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql @@ -0,0 +1,8 @@ +-- 1. Replace all NULL values with zero +UPDATE public."WellMeasurements" +SET value = 0 +WHERE value IS NULL; + +-- 2. Reinstate NOT NULL constraint +ALTER TABLE public."WellMeasurements" +ALTER COLUMN value SET NOT NULL; diff --git a/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql new file mode 100644 index 00000000..dbd638b2 --- /dev/null +++ b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql @@ -0,0 +1,8 @@ +-- 1. Drop NOT NULL constraint +ALTER TABLE public."WellMeasurements" +ALTER COLUMN value DROP NOT NULL; + +-- 2. Replace all zero values with NULL +UPDATE public."WellMeasurements" +SET value = NULL +WHERE value = 0;