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 (
+
+ );
+};
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 (
+
+
+ }
+ disabled={fileLimit !== undefined && files.length >= fileLimit} // disable when limit reached
+ >
+ Upload photos
+
+
+ {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}
+ />
)
@@ -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
-
+
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/NotFound.tsx b/frontend/src/views/NotFound.tsx
new file mode 100644
index 00000000..3d176f94
--- /dev/null
+++ b/frontend/src/views/NotFound.tsx
@@ -0,0 +1,31 @@
+import { Box, Button, Card, CardContent, Typography } from "@mui/material";
+import DoNotTouchIcon from '@mui/icons-material/DoNotTouch';
+import { BackgroundBox, CustomCardHeader } from "../components";
+import { Link } from "react-router-dom";
+import { Home } from "@mui/icons-material";
+
+export const NotFound = () => {
+ return (
+
+
+
+
+
+ Sorry, the page you are looking for does not exist or may have been moved.
+
+
+
+ }
+ >
+ Back to Home
+
+
+
+
+
+ );
+}
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:
+
-
- {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) {
-
+
-
+
- Set Password
+ Reset Password
@@ -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 ? (
-
-
- Save New User
-
- ) : (
-
-
- Save Changes
-
)}
+
+ {hasErrors() ? (
+
+ Please correct any errors before submission.
+
+ ) : userAddMode ? (
+
+
+ Save New User
+
+ ) : (
+
+
+ Save Changes
+
+ )}
+
);
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)} />
+
+ >
+ );
+}
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 (
+
+ );
+}
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)} />
-
- >
- );
-}
-
-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 (
-
- );
-}
+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;