diff --git a/.github/workflows/deploy-to-gcp.yml b/.github/workflows/deploy-to-gcp.yml new file mode 100644 index 00000000..13f9d838 --- /dev/null +++ b/.github/workflows/deploy-to-gcp.yml @@ -0,0 +1,57 @@ +name: Deploy to GCP Compute VM + +on: + push: + branches: + - pre-production + - production + +jobs: + deploy: + name: Deploy to GCP VM + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set environment + run: | + if [[ "${GITHUB_REF_NAME}" == "production" ]]; then + echo "SSH_HOST=${{ secrets.GCP_PROD_SSH_HOST }}" >> "$GITHUB_ENV" + echo "COMPOSE_FILE=docker-compose.production.yml" >> "$GITHUB_ENV" + else + echo "SSH_HOST=${{ secrets.GCP_PREPROD_SSH_HOST }}" >> "$GITHUB_ENV" + echo "COMPOSE_FILE=docker-compose.development.yml" >> "$GITHUB_ENV" + fi + + - name: Show CI key fingerprint + shell: bash + env: + KEY: ${{ github.ref_name == 'production' && secrets.GCP_PROD_SSH_PRIVATE_KEY || secrets.GCP_PREPROD_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + printf '%s\n' "$KEY" > key.pem + chmod 600 key.pem + echo -n "CI key fingerprint: " + ssh-keygen -yf key.pem | ssh-keygen -lf - + + # Use a single SSH step; choose the key by branch + - name: Deploy to VM via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ env.SSH_HOST }} + username: ${{ secrets.GCP_SSH_USER }} + key: ${{ github.ref_name == 'production' && secrets.GCP_PROD_SSH_PRIVATE_KEY || secrets.GCP_PREPROD_SSH_PRIVATE_KEY }} + script: | + set -e + cd ${{ secrets.DEPLOYMENT_PATH }} + + git fetch origin --prune + git checkout "${{ github.ref_name }}" + git pull --ff-only origin "${{ github.ref_name }}" + + docker compose -f "${{ env.COMPOSE_FILE }}" down + docker compose -f "${{ env.COMPOSE_FILE }}" up -d --build + + docker image prune -f diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml new file mode 100644 index 00000000..825d3309 --- /dev/null +++ b/.github/workflows/pull_requests.yml @@ -0,0 +1,35 @@ +name: PRs Docker Build Test + +on: + pull_request: + +jobs: + docker-build: + name: Test Production Docker Builds + runs-on: ubuntu-latest + + services: + docker: + image: docker:24.0.5 + options: --privileged + ports: + - 5173:80 + - 8000:8000 + - 8080:8080 + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Build Frontend Production Image + - name: Build Frontend Docker Image + run: | + docker build -t frontend-prod -f frontend/Dockerfile ./frontend + + # Build Backend Production Image + - name: Build API Docker Image + run: | + docker build -t api-prod -f api/Dockerfile ./api diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ea05d4e8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +All notable changes to **Meter Manager** will be documented in this file. + +| Version | Changes | +|-----------|---------| +| v0.2.0 | Parts-used report functional with PDF download | +| v0.1.52 | Deploy chlorides for admin testing | +| v0.1.51.1 | Increased frontend signout to 300 minutes | +| v0.1.51 | Improved monitoring well page | +| v0.1.50 | Fixed wells map bug and update register if part used | +| v0.1.49 | Added outside recorder wells to monitoring page | +| v0.1.48 | Changed well owner to be meter water users | +| v0.1.47 | Add TRSS grids to meter map and fixed meter register save bug | +| v0.1.46 | Change how data is displayed in Wells table | +| v0.1.45 | Color code meter markers on map by last PM | +| v0.1.44 | Fix bug in continuous monitoring well data and added data to OSE endpoint | +| v0.1.43 | Fix navigation from work orders to activity, add OSE endpoint for "data issues" | +| v0.1.42 | Fix pagination, add 'uninstall and hold' | +| v0.1.41 | Add UI for water source on wells and some other minor changes | +| v0.1.40 | Add register to UI on meter details | +| v0.1.39 | Default share ose when workorder, OSE access to register information | +| v0.1.38 | Change logout time to 8 hours, show work order count in navigation | +| v0.1.37.1 | Fix various work order bugs | +| v0.1.37 | Update OSE API to include ose_request_id and new endpoint | +| v0.1.36 | Improved work orders, testing still needed | +| v0.1.35.1 | Fix bug with well search failing on certain inputs | +| v0.1.35 | Update continuous data stream IDs for monitoring wells | +| v0.1.34 | Work orders ready for alpha testing, reordered monitoring wells | +| v0.1.33 | Add Meter Status Filter to Meters Table | +| v0.1.32 | Fix Monitoring Wells so that table updates after change | +| v0.1.31 | Added note "verified register ratio" and made it appear by default | +| v0.1.30 | Admin can edit monitoring well data (note that monitoring well table still not updating automatically) | +| v0.1.29 | Fixed bug preventing meter type change | +| v0.1.28 | Full admin UI on meter page | +| v0.1.27 | Give admin ability to add out of order activities, fix zoom on map, other minor changes | +| v0.1.26 | Add functional merge button for admin | +| v0.1.25 | Fix datesort on meter history, give techs limited well management | +| v0.1.24 | Add non-functional merge button for initial testing | +| v0.1.23 | Prevent duplicate activities from being added | +| v0.1.22 | Change ownership so there is now water_users and meter_owner | +| v0.1.21 | Implement Degrees Minutes Seconds (DMS) for lat/long | +| v0.1.20 | Fix monitoring wells sort | +| v0.1.19 | Updated OSE endpoint to have activity_id, reorganized data returned | +| v0.1.18 | Only require well on install activity, display OSE tag | +| v0.1.17 | Restructure security code to prevent database connection problems | +| v0.1.16 | Fixed bug where status is changed when clearing well from meter | +| v0.1.15 | Updated backend to use SQLAlchemy 2 (resolve connection issue?) | +| v0.1.14 | Display RA number instead of well name, well distance is now observation, new default observations | +| v0.1.13 | Add checkbox for sharing activities with OSE | +| v0.1.12 | Change lat/long to DMS, reorder observation inputs, block out of order activities | +| v0.1.11 | Remove all async code to see if it fixes deadlock issue | +| v0.1.10 | Fix owners and osetag on Wells page | +| v0.1.9 | Add owners to Meters table, fix various bugs | +| v0.1.8 | Fix bug in meter selection autocomplete | +| v0.1.7 | Fixed bugs in Add Meter | +| v0.1.6 | Various fixes and meter search via map UI | +| v0.1.5 | Various minor bug fixes | +| v0.1.4 | Updated "current installation" section of activities to match Meters page | +| v0.1.3 | Added user admin, improved appearance, fixed OSE endpoint scope | +| v0.1.2 | Added an initial parts inventory and minor meter installation UI improvements | +| v0.1.1 | Initial version with new clean database | +| v0.0.0 | Initial minimum viable product | diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/README.md b/README.md index 9c13e614..35bacfb3 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,31 @@ -# WaterManagerDB +# Meter Manager -[![Unittests](https://github.com/NMWDI/WaterManagerDB/actions/workflows/testing.yml/badge.svg)](https://github.com/NMWDI/WaterManagerDB/actions/workflows/testing.yml) -[![Format code](https://github.com/NMWDI/WaterManagerDB/actions/workflows/format_code.yml/badge.svg)](https://github.com/NMWDI/WaterManagerDB/actions/workflows/format_code.yml) +### Purpose -## Versions -- V0.1.52 - Deploy chlorides for admin testing -- V0.1.51.1 - Increased frontend signout to 300 minutes -- V0.1.51 - Improved monitoring well page -- V0.1.50 - Fixed wells map bug and update register if part used -- V0.1.49 - Added outside recorder wells to monitoring page -- V0.1.48 - Changed well owner to be meter water users -- V0.1.47 - Add TRSS grids to meter map and fixed meter register save bug -- V0.1.46 - Change how data is displayed in Wells table -- V0.1.45 - Color code meter markers on map by last PM -- V0.1.44 - Fix bug in continuous monitoring well data and added data to OSE endpoint -- V0.1.43 - Fix navigation from work orders to activity, add OSE endpoint for "data issues" -- V0.1.42 - Fix pagination, add 'uninstall and hold' -- V0.1.41 - Add UI for water source on wells and some other minor changes -- V0.1.40 - Add register to UI on meter details -- V0.1.39 - Default share ose when workorder, OSE access to register information -- V0.1.38 - Change logout time to 8 hours, show work order count in navigation -- V0.1.37.1 - Fix various work order bugs -- V0.1.37 - Update OSE API to include ose_request_id and new endpoint -- V0.1.36 - Improved work orders, testing still needed -- V0.1.35.1 - Fix bug with well search failing on certain inputs -- V0.1.35 - Update continuous data stream IDs for monitoring wells -- V0.1.34 - Work orders ready for alpha testing, reordered monitoring wells -- V0.1.33 - Add Meter Status Filter to Meters Table -- V0.1.32 - Fix Monitoring Wells so that table updates after change -- V0.1.31 - Added note "verified register ratio" and made it appear by default -- V0.1.30 - Admin can edit monitoring well data (note that monitoring well table still not updating automatically) -- V0.1.29 - Fixed bug preventing meter type change -- V0.1.28 - Full admin UI on meter page -- V0.1.27 - Give admin ability to add out of order activities, fix zoom on map, other minor changes -- V0.1.26 - Add functional merge button for admin -- V0.1.25 - Fix datesort on meter history, give techs limited well management -- V0.1.24 - Add non-functional merge button for initial testing -- V0.1.23 - Prevent duplicate activities from being added -- V0.1.22 - Change ownership so there is now water_users and meter_owner -- V0.1.21 - Implement Degrees Minutes Seconds (DMS) for lat/long -- V0.1.20 - Fix monitoring wells sort -- V0.1.19 - Updated OSE endpoint to have activity_id, reorganized data returned -- V0.1.18 - Only require well on install activity, display OSE tag -- V0.1.17 - Restructure security code to prevent database connection problems -- V0.1.16 - Fixed bug where status is changed when clearing well from meter -- V0.1.15 - Updated backend to use SQLAlchemy 2 (resolve connection issue?) -- V0.1.14 - Display RA number instead of well name, well distance is now observation, new default observations -- V0.1.13 - Add checkbox for sharing activities with OSE. -- V0.1.12 - Change lat/long to DMS, reorder observation inputs, block out of order activities -- V0.1.11 - Remove all async code to see if it fixes deadlock issue -- V0.1.10 - Fix owners and osetag on Wells page -- V0.1.9 - Add owners to Meters table, fix various bugs -- V0.1.8 - Fix bug in meter selection autocomplete -- V0.1.7 - Fixed bugs in Add Meter -- V0.1.6 - Various fixes and meter search via map UI -- V0.1.5 - Various minor bug fixes -- V0.1.4 - Updated "current installation" section of activities to match Meters page -- V0.1.3 - Added user admin, improved appearance, fixed OSE endpoint scope. -- V0.1.2 - Added an initial parts inventory and minor meter installation UI improvements -- V0.1.1 - Initial version with new clean database -- V0.0 - Initial minimum viable product +**Meter Manager** is a web application designed to help **PVACD** manage its water data. It provides tools for spatial visualization, maintenance tracking, and regulatory reporting. -## Purpose -This web app facilitates reporting of water management operations to other organizations. The initial goal is to help water conservation districts communicate with local and state governments. However, the interface may eventually be developed to be more general. +--- -## Installation -The app is built with the following components: -* PostgreSQL database with PostGIS extension -* Python FastAPI backend for interfacing with database -* React based frontend +### Features -App components are organized into Docker containers, but it can also be run locally. +- πŸ—ΊοΈ Interactive map UI for meters and wells +- πŸ”§ Meter activities, maintenance history, and preventive maintenance (PM) tracking +- πŸ“¦ Inventory and part usage tracking +- πŸ“‹ Work order and technician assignment system +- πŸ“‘ OSE-compliant reporting endpoint +- πŸ› οΈ Admin features for editing, merging, and managing records +- πŸ‘₯ Role-based access control (techs, admins, etc.) +- πŸ›°οΈ TRSS grid overlays for spatial reference +- πŸ’§ Continuous monitoring support for observation wells -To run the app, clone the repository and use Docker Compose to run: -``` -/watermanagerdb >> docker compose -f docker-compose.dev.yml --build -``` +--- -The API component will need several environmental variables that should be specified in the file 'api/.env'. See api/.env_example for an example. The PostgreSQL environmental variables should match the database settings. +### Tech Stack + +| Layer | Technology | +|---------------|----------------------| +| **Frontend** | React + TypeScript | +| **Backend** | FastAPI (Python) | +| **Database** | PostgreSQL + PostGIS | +| **Container** | Docker Compose | +| **CI/CD** | GitHub Actions | diff --git a/api/Dockerfile b/api/Dockerfile index cacf5106..9c3e543e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,17 +1,31 @@ -# -FROM python:3.10 +FROM python:3.12.11 -# -WORKDIR . +WORKDIR /app -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 -# +# Install system dependencies (trixie) + JDK (default is 21) + PostgreSQL client +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + default-jdk-headless \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Make Java headers discoverable by builds +ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 +ENV JDK_HOME=$JAVA_HOME +ENV PATH="${JAVA_HOME}/bin:${PATH}" +ENV PYTHONPATH=/app + +# Now install the rest of the requirements COPY ./requirements.txt . +RUN pip install --no-cache-dir --upgrade pip setuptools wheel \ + && pip install --no-cache-dir -r requirements.txt -# -RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY . /app/api -# -COPY . . \ No newline at end of file diff --git a/api/Dockerfile_dev b/api/Dockerfile_dev deleted file mode 100644 index 6408ed13..00000000 --- a/api/Dockerfile_dev +++ /dev/null @@ -1,19 +0,0 @@ -## -#FROM python:3.9 -# -## -#WORKDIR . -# -#ENV PYTHONDONTWRITEBYTECODE 1 -#ENV PYTHONUNBUFFERED 1 -# -#RUN apt update -#RUN apt install libsqlite3-mod-spatialite -y -## -#COPY ./requirements.txt . -# -## -#RUN pip install --no-cache-dir --upgrade -r requirements.txt -# -## dont need to copy because volume mapped. -##COPY . . \ No newline at end of file diff --git a/api/main.py b/api/main.py index 8066b3d9..99ecf150 100644 --- a/api/main.py +++ b/api/main.py @@ -23,12 +23,14 @@ from api.schemas import security_schemas from api.models.main_models import Users -from api.routes.meters import meter_router -from api.routes.well_measurements import well_measurement_router from api.routes.activities import activity_router +from api.routes.admin import admin_router +from api.routes.chlorides import chlorides_router +from api.routes.maintenance import maintenance_router +from api.routes.meters import meter_router from api.routes.OSE import ose_router from api.routes.parts import part_router -from api.routes.admin import admin_router +from api.routes.well_measurements import well_measurement_router from api.routes.wells import well_router from api.security import ( @@ -126,11 +128,13 @@ def login_for_access_token( # ======================================= -authenticated_router.include_router(meter_router) authenticated_router.include_router(activity_router) -authenticated_router.include_router(well_measurement_router) -authenticated_router.include_router(part_router) authenticated_router.include_router(admin_router) +authenticated_router.include_router(chlorides_router) +authenticated_router.include_router(maintenance_router) +authenticated_router.include_router(meter_router) +authenticated_router.include_router(part_router) +authenticated_router.include_router(well_measurement_router) authenticated_router.include_router(well_router) add_pagination(app) diff --git a/api/models/main_models.py b/api/models/main_models.py index 5fc7a208..fcd56376 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -69,6 +69,7 @@ class Parts(Base): note: Mapped[Optional[str]] in_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) commonly_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + price: Mapped[Optional[float]] = mapped_column(Float) part_type_id: Mapped[int] = mapped_column( Integer, ForeignKey("PartTypeLU.id"), nullable=False @@ -580,6 +581,7 @@ class meterRegisters(Base): __tablename__ = "meter_registers" brand: Mapped[str] = mapped_column(String, nullable=False) meter_size: Mapped[float] = mapped_column(Float, nullable=False) + part_id: Mapped[int] = mapped_column(Integer, ForeignKey("Parts.id")) ratio: Mapped[str] = mapped_column(String) dial_units_id: Mapped[int] = mapped_column(Integer, ForeignKey("Units.id"), nullable=False) totalizer_units_id: Mapped[int] = mapped_column(Integer, ForeignKey("Units.id"), nullable=False) diff --git a/api/requirements.txt b/api/requirements.txt index a94165bf..4e25f042 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,40 +1,72 @@ -annotated-types==0.6.0 -anyio==3.7.1 -bcrypt==4.0.1 -cffi==1.16.0 -click==8.1.7 -colorama==0.4.6 -cryptography==41.0.7 -dnspython==2.4.2 -ecdsa==0.18.0 -email-validator==2.1.0.post1 -exceptiongroup==1.2.0 -fastapi==0.105.0 -fastapi-pagination==0.12.14 -GeoAlchemy2==0.14.2 -greenlet==3.0.2 -h11==0.14.0 -httptools==0.6.1 -idna==3.6 -packaging==23.2 +Cython>=3.1 +pyjnius>=1.6.1 + +annotated-types==0.7.0 +anyio==4.9.0 +attr==0.3.2 +bcrypt==4.3.0 +brotli==1.1.0 +cffi==1.17.1 +click==8.2.1 +ConfigParser==7.2.0 +contourpy==1.3.2 +cryptography==45.0.4 +cssselect2==0.8.0 +cycler==0.12.1 +docutils==0.21.2 +dotenv==0.9.9 +ecdsa==0.19.1 +fastapi==0.116.1 +fastapi-pagination==0.13.3 +filelock==3.18.0 +fonttools==4.58.5 +GeoAlchemy2==0.17.1 +h11==0.16.0 +HTMLParser==0.0.2 +idna==3.10 +ipython==8.12.3 +ipywidgets==8.1.7 +Jinja2==3.1.6 +keyring==25.6.0 +kiwisolver==1.4.8 +MarkupSafe==3.0.2 +matplotlib==3.10.3 +numpy==2.3.1 +packaging==25.0 passlib==1.7.4 -psycopg==3.1.16 -psycopg-binary==3.1.16 -pyasn1==0.5.1 -pycparser==2.21 -pydantic==2.5.2 -pydantic_core==2.14.5 -python-dotenv==1.0.0 -python-jose==3.3.0 -python-multipart==0.0.6 -PyYAML==6.0.1 -rsa==4.9 -six==1.16.0 -sniffio==1.3.0 -SQLAlchemy==2.0.23 -starlette==0.27.0 -typing_extensions==4.9.0 -tzdata==2023.3 -uvicorn==0.25.0 -watchfiles==0.21.0 -websockets==12.0 +pillow==11.3.0 +protobuf==6.31.1 +psycopg==3.2.9 +pyasn1==0.6.1 +pycparser==2.22 +pydantic==2.11.7 +pydantic_core==2.33.2 +pydyf==0.11.0 +pyOpenSSL==25.1.0 +pyparsing==3.2.3 +pyphen==0.17.2 +pytest==8.4.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.1 +python-jose==3.5.0 +python-multipart==0.0.20 +redis==6.2.0 +rsa==4.9.1 +ruff==0.12.3 +six==1.17.0 +sniffio==1.3.1 +Sphinx==8.2.3 +SQLAlchemy==2.0.41 +starlette==0.47.1 +thread==2.0.5 +tinycss2==1.4.0 +tinyhtml5==2.0.0 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +urllib3_secure_extra==0.1.0 +uvicorn==0.35.0 +weasyprint==65.1 +webencodings==0.5.1 +xlsxwriter==3.2.5 +zopfli==0.2.3.post1 +google-cloud-storage==3.3.0 diff --git a/api/routes/activities.py b/api/routes/activities.py index 7b09661b..068063d8 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -28,7 +28,6 @@ from api.session import get_db from api.security import get_current_user from api.enums import ScopedUser, WorkOrderStatus -from api.route_util import _patch activity_router = APIRouter() @@ -123,7 +122,7 @@ def post_activity( try: db.add(meter_activity) db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException( status_code=409, detail="Activity overlaps with existing activity." ) @@ -466,63 +465,73 @@ def get_service_types(db: Session = Depends(get_db)): def get_note_types(db: Session = Depends(get_db)): return db.scalars(select(NoteTypeLU)).all() -# Get work orders endpoint @activity_router.get( "/work_orders", dependencies=[Depends(ScopedUser.Read)], - response_model=List[meter_schemas.WorkOrder], tags=["Work Orders"], ) def get_work_orders( - filter_by_status: list[WorkOrderStatus] = Query('Open'), + filter_by_status: list[WorkOrderStatus] = Query(['Open']), start_date: datetime = Query(datetime.strptime('2024-06-01', '%Y-%m-%d')), - db: Session = Depends(get_db) - ): + db: Session = Depends(get_db), +): query_stmt = ( select(workOrders) .options( joinedload(workOrders.status), joinedload(workOrders.meter), - joinedload(workOrders.assigned_user) + joinedload(workOrders.assigned_user), ) .join(workOrderStatusLU) .where(workOrderStatusLU.name.in_(filter_by_status)) .where(workOrders.date_created >= start_date) ) - work_orders: list[workOrders] = db.scalars(query_stmt).all() + work_orders = db.scalars(query_stmt).all() - # I was unable to get associated_activities to work with joinedload, so I'm doing it manually here - relevant_activities = db.scalars(select(MeterActivities).where(MeterActivities.work_order_id.in_([wo.id for wo in work_orders]))).all() - - # Create a dictionary where the key is the work order ID and the value is a list of associated activities - associated_activities = {} - for activity in relevant_activities: - if activity.work_order_id in associated_activities: - associated_activities[activity.work_order_id].append(activity) - else: - associated_activities[activity.work_order_id] = [activity] - - # Create a WorkOrder schema for each work order returned - output_work_orders = [] - for wo in work_orders: - work_order_schema = meter_schemas.WorkOrder( - work_order_id = wo.id, - ose_request_id=wo.ose_request_id, - date_created = wo.date_created, - creator = wo.creator, - meter_id = wo.meter.id, - meter_serial = wo.meter.serial_number, - title = wo.title, - description = wo.description, - status = wo.status.name, - notes = wo.notes, - assigned_user_id = wo.assigned_user_id, - assigned_user= wo.assigned_user.username if wo.assigned_user else None, - associated_activities=associated_activities[wo.id] if wo.id in associated_activities else [] - ) - output_work_orders.append(work_order_schema) + # grab activities separately + relevant_activities = db.scalars( + select(MeterActivities) + .options(joinedload(MeterActivities.location)) + .where(MeterActivities.work_order_id.in_([wo.id for wo in work_orders])) + ).all() - return output_work_orders + # group activities by work_order_id + activities_by_wo = {} + for act in relevant_activities: + activities_by_wo.setdefault(act.work_order_id, []).append({ + "id": act.id, + "timestamp_start": act.timestamp_start, + "timestamp_end": act.timestamp_end, + "description": act.description, + "submitting_user_id": act.submitting_user_id, + "meter_id": act.meter_id, + "activity_type_id": act.activity_type_id, + "location_id": act.location_id, + "location_name": act.location.name if act.location else None, + "ose_share": act.ose_share, + "water_users": act.water_users, + }) + + # build output + output = [] + for wo in work_orders: + output.append({ + "work_order_id": wo.id, + "ose_request_id": wo.ose_request_id, + "date_created": wo.date_created, + "creator": wo.creator, + "meter_id": wo.meter.id, + "meter_serial": wo.meter.serial_number, + "title": wo.title, + "description": wo.description, + "status": wo.status.name, + "notes": wo.notes, + "assigned_user_id": wo.assigned_user_id, + "assigned_user": wo.assigned_user.username if wo.assigned_user else None, + "associated_activities": activities_by_wo.get(wo.id, []), + }) + + return output # Create work order endpoint @activity_router.post( @@ -564,7 +573,7 @@ def create_work_order(new_work_order: meter_schemas.CreateWorkOrder, db: Session try: db.add(work_order) db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException( status_code=409, detail="Title empty or already exists for this meter." @@ -661,7 +670,7 @@ def patch_work_order(patch_work_order_form: meter_schemas.PatchWorkOrder, user: # Database should block empty title and non-unique (date, title, meter_id) combinations try: db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException( status_code=409, detail="Title already exists for this meter." diff --git a/api/routes/admin.py b/api/routes/admin.py index 0d0a8b18..432d4946 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -11,10 +11,23 @@ from api.route_util import _patch from api.enums import ScopedUser +from pathlib import Path +from google.cloud import storage +from dotenv import load_dotenv + +import os +import subprocess +import datetime + admin_router = APIRouter() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - +BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") +BACKUP_PREFIX = os.getenv("GCP_BACKUP_PREFIX", "") +BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "30")) +load_dotenv(os.getenv("APPDB_ENV", ".env")) +DATABASE_URL = os.getenv("DATABASE_URL", "") + # define response models @admin_router.post( "/users/update_password", @@ -195,3 +208,52 @@ def update_role(updated_role: security_schemas.UserRole, db: Session = Depends(g .where(UserRoles.id == updated_role.id) .options(joinedload(UserRoles.security_scopes)) ).first() + + +@admin_router.api_route( + "/backup-db/", + methods=["BACKUP"], + tags=["Admin"], + dependencies=[Depends(ScopedUser.Admin)] +) +def backup_and_send(): + if not BUCKET_NAME: + raise ValueError("GCP_BUCKET_NAME environment variable is not set") + if not DATABASE_URL: + raise ValueError("DATABASE_URL environment variable is not set") + + # Use UTC-aware timestamp + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H%M%S") + filename = f"backup-{timestamp}.dump" + local_path = Path(f"/tmp/{filename}") + + subprocess.run( + ["pg_dump", "-Fc", DATABASE_URL, "-f", str(local_path)], + check=True + ) + + client = storage.Client() + bucket = client.bucket(BUCKET_NAME) + + blob_name = f"{BACKUP_PREFIX}/{filename}" if BACKUP_PREFIX else filename + blob = bucket.blob(blob_name) + blob.upload_from_filename(local_path) + + print(f"Backup uploaded to gs://{BUCKET_NAME}/{blob_name}") + + local_path.unlink(missing_ok=True) + + # Delete old backups (> BACKUP_RETENTION_DAYS) using UTC-aware cutoff + cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=BACKUP_RETENTION_DAYS) + blobs = client.list_blobs(BUCKET_NAME, prefix=BACKUP_PREFIX) + + deleted = [] + for old_blob in blobs: + if old_blob.time_created < cutoff_date: + old_blob.delete() + deleted.append(old_blob.name) + + return { + "status": f"Database backup uploaded to gs://{BUCKET_NAME}/{blob_name}", + "deleted_old_backups": deleted + } diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py new file mode 100644 index 00000000..add44419 --- /dev/null +++ b/api/routes/chlorides.py @@ -0,0 +1,366 @@ +from typing import Optional, List +from datetime import datetime +import calendar +from fastapi.responses import StreamingResponse +from weasyprint import HTML +from io import BytesIO +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import and_, select +from sqlalchemy.orm import Session, joinedload + +from api.schemas import well_schemas +from api.models.main_models import WellMeasurements, Wells, Locations, WellUseLU +from api.session import get_db +from api.enums import ScopedUser, SortDirection + +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +import matplotlib +matplotlib.use("Agg") # Force non-GUI backend + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) + +chlorides_router = APIRouter() + +@chlorides_router.get( + "/chlorides", + dependencies=[Depends(ScopedUser.Read)], + response_model=List[well_schemas.WellMeasurementDTO], + tags=["Chlorides"], +) +def read_chlorides( + chloride_group_id: int = Query(..., description="Chloride group ID to filter by"), + db: Session = Depends(get_db) +): + chloride_concentration_group_id = 5 + + return db.scalars( + select(WellMeasurements) + .options( + joinedload(WellMeasurements.submitting_user), + joinedload(WellMeasurements.well) + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .where( + and_( + WellMeasurements.observed_property_id == chloride_concentration_group_id, + Wells.chloride_group_id == chloride_group_id + ) + ) + ).all() + + +@chlorides_router.get( + "/chloride_groups", + dependencies=[Depends(ScopedUser.Read)], + response_model=List[well_schemas.ChlorideGroupResponse], + tags=["Chlorides"], +) +def get_chloride_groups( + sort_direction: SortDirection = SortDirection.Ascending, + db: Session = Depends(get_db), +): + query = ( + select(Wells) + .options(joinedload(Wells.location), joinedload(Wells.use_type)) + .join(Locations, isouter=True) + .join(WellUseLU, isouter=True) + .where(Wells.chloride_group_id.isnot(None)) + ) + + if sort_direction == SortDirection.Ascending: + query = query.order_by(Wells.chloride_group_id.asc()) + else: + query = query.order_by(Wells.chloride_group_id.desc()) + + wells = db.scalars(query).all() + + groups = {} + for well in wells: + group_id = well.chloride_group_id + if group_id not in groups: + groups[group_id] = [] + if well.ra_number: + groups[group_id].append(well.ra_number) + + return [ + {"id": group_id, "names": sorted(names)} + for group_id, names in groups.items() + ] + +class MinMaxAvg(BaseModel): + min: Optional[float] = None + max: Optional[float] = None + avg: Optional[float] = None + + +class ChlorideReportNums(BaseModel): + north: MinMaxAvg + south: MinMaxAvg + east: MinMaxAvg + west: MinMaxAvg + + +@chlorides_router.get( + "/chlorides/report", + dependencies=[Depends(ScopedUser.Read)], + response_model=ChlorideReportNums, + tags=["Chlorides"], +) +def get_chlorides_report( + from_month: Optional[str] = Query( + None, + description="Month start, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + to_month: Optional[str] = Query( + None, + description="Month end, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + db: Session = Depends(get_db), +): + """ + Returns min/max/avg for north/south/east/west halves **within the SE quadrant of New Mexico**, + over the specified [from_month, to_month] inclusive range, for chloride wells in the given group. + """ + + CHLORIDE_OBSERVED_PROPERTY_ID = 5 + + # Parse months + start_dt = _parse_month(from_month) if from_month else None + end_dt = _parse_month(to_month) if to_month else None + if start_dt and not end_dt: + end_dt = start_dt + if end_dt: + end_dt = _month_end(end_dt) + + stmt = ( + select( + WellMeasurements.value, + Locations.latitude, + Locations.longitude, + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .join(Locations, Locations.id == Wells.location_id) + .where( + and_( + WellMeasurements.observed_property_id == CHLORIDE_OBSERVED_PROPERTY_ID, + Locations.latitude.is_not(None), + Locations.longitude.is_not(None), + # Restrict to NM bbox first + Locations.latitude >= NM_LAT_MIN, + Locations.latitude <= NM_LAT_MAX, + Locations.longitude >= NM_LON_MIN, + Locations.longitude <= NM_LON_MAX, + # Time range (optional) + *( [WellMeasurements.timestamp >= start_dt] if start_dt else [] ), + *( [WellMeasurements.timestamp <= end_dt] if end_dt else [] ), + ) + ) + ) + + rows = db.execute(stmt).all() + + se_rows = [ + (val, lat, lon) + for (val, lat, lon) in rows + if (lat is not None and lon is not None + and SE_MIN_LAT <= float(lat) <= SE_MAX_LAT + and SE_MIN_LON <= float(lon) <= SE_MAX_LON) + ] + + north_vals: List[float] = [] + south_vals: List[float] = [] + east_vals: List[float] = [] + west_vals: List[float] = [] + + for val, lat, lon in se_rows: + # North vs South halves within the SE quadrant + if float(lat) >= SE_MID_LAT: + north_vals.append(float(val)) + else: + south_vals.append(float(val)) + + # East vs West halves within the SE quadrant + if float(lon) >= SE_MID_LON: + east_vals.append(float(val)) + else: + west_vals.append(float(val)) + + return ChlorideReportNums( + north=_stats(north_vals), + south=_stats(south_vals), + east=_stats(east_vals), + west=_stats(west_vals), + ) + + +@chlorides_router.get( + "/chlorides/report/pdf", + dependencies=[Depends(ScopedUser.Read)], + tags=["Chlorides"], +) +def download_chlorides_report_pdf( + from_month: Optional[str] = Query( + None, + description="Month start, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + to_month: Optional[str] = Query( + None, + description="Month end, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + db: Session = Depends(get_db), +): + """ + Generate a PDF chloride report (north/south/east/west stats) + for the SE quadrant of New Mexico. + """ + # Re-use your existing logic by calling the data endpoint’s function + report = get_chlorides_report(from_month=from_month, to_month=to_month, db=db) + + # Render HTML using a template + template = templates.get_template("chlorides_report.html") + html_content = template.render( + report=report, + from_month=from_month, + to_month=to_month, + ) + + # Convert to PDF + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=chlorides_report.pdf" + }, + ) + +@chlorides_router.post( + "/chlorides", + dependencies=[Depends(ScopedUser.WellMeasurementWrite)], + response_model=well_schemas.ChlorideMeasurement, + tags=["Chlorides"], +) +def add_chloride_measurement( + chloride_measurement: well_schemas.WellMeasurement, + db: Session = Depends(get_db), +): + # Create a new chloride measurement as a WellMeasurement + well_measurement = WellMeasurements( + timestamp = chloride_measurement.timestamp, + value = chloride_measurement.value, + observed_property_id = 5, # Chloride Concentration + submitting_user_id = chloride_measurement.submitting_user_id, + unit_id = chloride_measurement.unit_id, + well_id = chloride_measurement.well_id + ) + + db.add(well_measurement) + db.commit() + + return well_measurement + +@chlorides_router.patch( + "/chlorides", + dependencies=[Depends(ScopedUser.WellMeasurementWrite)], + response_model=well_schemas.WellMeasurement, + tags=["Chlorides"], +) +def patch_chloride_measurement( + chloride_measurement_patch: well_schemas.PatchChlorideMeasurement, + db: Session = Depends(get_db), +): + # Find the measurement + well_measurement = ( + db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_patch.id)).first() + ) + + # Update the fields, all are mandatory + well_measurement.submitting_user_id = chloride_measurement_patch.submitting_user_id + well_measurement.timestamp = chloride_measurement_patch.timestamp + well_measurement.value = chloride_measurement_patch.value + well_measurement.unit_id = chloride_measurement_patch.unit_id + well_measurement.well_id = chloride_measurement_patch.well_id + + db.commit() + + return well_measurement + +@chlorides_router.delete( + "/chlorides", + dependencies=[Depends(ScopedUser.Admin)], + tags=["Chlorides"], +) +def delete_chloride_measurement(chloride_measurement_id: int, db: Session = Depends(get_db)): + # Find the measurement + well_measurement = ( + db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_id)).first() + ) + + db.delete(well_measurement) + db.commit() + + return True + + +def _parse_month(m: Optional[str]) -> Optional[datetime]: + """ + Accepts 'YYYY-MM' or 'YYYY MM'. Returns the first day of month at 00:00:00. + """ + if not m: + return None + m = m.strip() + # Try 'YYYY-MM' + for fmt in ("%Y-%m", "%Y %m"): + try: + dt = datetime.strptime(m, fmt) + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + except ValueError: + continue + raise HTTPException(status_code=400, detail="Invalid month format. Use 'YYYY-MM' or 'YYYY MM'.") + +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)) + ) + +# Approx NM bounding box (degrees) +NM_LAT_MIN = 33.12500 +NM_LAT_MAX = 34.12500 +NM_LON_MIN = -105.25000 +NM_LON_MAX = -104.25000 + +# Precompute midlines for quadrants +NM_MID_LAT = (NM_LAT_MIN + NM_LAT_MAX) / 2.0 +NM_MID_LON = (NM_LON_MIN + NM_LON_MAX) / 2.0 + +# Southeast quadrant bounds +SE_MIN_LAT = NM_LAT_MIN +SE_MAX_LAT = NM_MID_LAT +SE_MIN_LON = NM_MID_LON +SE_MAX_LON = NM_LON_MAX + +SE_MID_LAT = (SE_MIN_LAT + SE_MAX_LAT) / 2.0 +SE_MID_LON = (SE_MIN_LON + SE_MAX_LON) / 2.0 diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py new file mode 100644 index 00000000..104fb77e --- /dev/null +++ b/api/routes/maintenance.py @@ -0,0 +1,329 @@ +from fastapi import Depends, APIRouter, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List +from datetime import datetime +import calendar +from fastapi.responses import StreamingResponse +from weasyprint import HTML +from io import BytesIO +from collections import defaultdict +from matplotlib.pyplot import figure, close +from base64 import b64encode +from api.models.main_models import ( + Users, + Meters, + MeterActivities, + ActivityTypeLU, + Locations, +) +from api.session import get_db +from api.enums import ScopedUser +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +import matplotlib +matplotlib.use("Agg") # Force non-GUI backend + + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) + +maintenance_router = APIRouter() + + +class MeterSummary(BaseModel): + meter: str + count: int + + +class MaintenanceRow(BaseModel): + date_time: datetime + technician: str + meter: str + trss: str + number_of_repairs: int + number_of_pms: int + + +class MaintenanceSummaryResponse(BaseModel): + repairs_by_meter: List[MeterSummary] + pms_by_meter: List[MeterSummary] + table_rows: List[MaintenanceRow] + + +@maintenance_router.get( + "/maintenance", + tags=["Maintenance"], + response_model=MaintenanceSummaryResponse, + dependencies=[Depends(ScopedUser.Read)], +) +def get_maintenance_summary( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + trss: str = Query(...), + technicians: List[int] = Query(...), + db: Session = Depends(get_db), +): + # Parse from/to month into datetime range + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + # Filter by technicians if -1 is not present + filter_techs = -1 not in technicians + + # Optional TRSS-based meter filtering + matching_meter_ids = None + if trss: + try: + # normalize input (strip spaces) + trss_str = trss.strip() + + location_ids = ( + db.query(Locations.id) + .filter(Locations.trss.like(f"{trss_str}%")) + .all() + ) + location_ids = [loc_id for (loc_id,) in location_ids] + + if location_ids: + meter_subq = ( + db.query(Meters.id) + .filter(Meters.location_id.in_(location_ids)) + ) + matching_meter_ids = [m_id for (m_id,) in meter_subq.all()] + except Exception: + pass # Ignore invalid TRSS input silently + + # Base query + query = ( + db.query( + MeterActivities.timestamp_start.label("date_time"), + Users.full_name.label("technician"), + Meters.serial_number.label("meter"), + ActivityTypeLU.name.label("activity_type"), + Locations.trss.label("trss") + ) + .join(Users, Users.id == MeterActivities.submitting_user_id) + .join(Meters, Meters.id == MeterActivities.meter_id) + .join( + ActivityTypeLU, + ActivityTypeLU.id == MeterActivities.activity_type_id + ) + .join(Locations, Locations.id == Meters.location_id, isouter=True) + .filter(MeterActivities.timestamp_start >= from_date) + .filter(MeterActivities.timestamp_start <= to_date) + ) + + if filter_techs: + query = query.filter( + MeterActivities.submitting_user_id.in_(technicians) + ) + + if matching_meter_ids is not None: + if not matching_meter_ids: + # TRSS valid but no meters matched β†’ return empty results + return { + "repairs_by_meter": [], + "pms_by_meter": [], + "table_rows": [], + } + query = query.filter(MeterActivities.meter_id.in_(matching_meter_ids)) + + base_query = query.order_by(MeterActivities.timestamp_start).all() + + # Aggregate repairs and PMs + repairs_by_meter = defaultdict(int) + pms_by_meter = defaultdict(int) + grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) + + for row in base_query: + key = (row.date_time, row.technician, row.meter, row.trss) + if row.activity_type == "Repair": + repairs_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_repairs"] += 1 + elif row.activity_type == "Preventative Maintenance": + pms_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_pms"] += 1 + + repairs_result = [{"meter": meter, "count": count} for meter, count in repairs_by_meter.items()] + pms_result = [{"meter": meter, "count": count} for meter, count in pms_by_meter.items()] + + table_rows = [] + for (date_time, technician, meter, trss), counts in grouped_rows.items(): + table_rows.append({ + "date_time": date_time, + "technician": technician, + "meter": meter, + "trss": trss or "", + "number_of_repairs": counts["number_of_repairs"], + "number_of_pms": counts["number_of_pms"], + }) + + return { + "repairs_by_meter": repairs_result, + "pms_by_meter": pms_result, + "table_rows": table_rows, + } + + +@maintenance_router.get( + "/maintenance/pdf", + tags=["Maintenance"], + dependencies=[Depends(ScopedUser.Read)], +) +def download_parts_used_pdf( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + trss: str = Query(...), + technicians: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") + + filter_techs = -1 not in technicians + + # Optional TRSS filtering via Locations β†’ Meters + matching_meter_ids = None + if trss: + try: + parts = list(map(int, trss.strip().split("."))) + if len(parts) >= 4: + township, range_, section, quarter = parts[:4] + + location_ids = [ + loc_id for (loc_id,) in db.query(Locations.id).filter( + Locations.township == township, + Locations.range == range_, + Locations.section == section, + Locations.quarter == quarter, + ).all() + ] + + if location_ids: + matching_meter_ids = [ + meter_id for (meter_id,) in db.query(Meters.id).filter( + Meters.location_id.in_(location_ids) + ).all() + ] + except Exception: + pass # Silently skip TRSS filtering if malformed + + query = ( + db.query( + MeterActivities.timestamp_start.label("date_time"), + Users.full_name.label("technician"), + Meters.serial_number.label("meter"), + ActivityTypeLU.name.label("activity_type") + ) + .join(Users, Users.id == MeterActivities.submitting_user_id) + .join(Meters, Meters.id == MeterActivities.meter_id) + .join( + ActivityTypeLU, + ActivityTypeLU.id == MeterActivities.activity_type_id + ) + .filter(MeterActivities.timestamp_start >= from_date) + .filter(MeterActivities.timestamp_start <= to_date) + ) + + if filter_techs: + query = query.filter(MeterActivities.submitting_user_id.in_(technicians)) + + if matching_meter_ids is not None: + if not matching_meter_ids: + return StreamingResponse(BytesIO(), media_type="application/pdf") # Empty PDF + query = query.filter(MeterActivities.meter_id.in_(matching_meter_ids)) + + base_query = query.order_by(MeterActivities.timestamp_start).all() + + repairs_by_meter = defaultdict(int) + pms_by_meter = defaultdict(int) + grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) + + for row in base_query: + key = (row.date_time, row.technician, row.meter) + if row.activity_type == "Repair": + repairs_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_repairs"] += 1 + elif row.activity_type == "Preventative Maintenance": + pms_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_pms"] += 1 + + table_rows = [] + for (date_time, technician, meter), counts in grouped_rows.items(): + table_rows.append({ + "date_time": date_time.strftime("%Y-%m-%d %H:%M"), + "technician": technician, + "meter": meter, + "number_of_repairs": counts["number_of_repairs"], + "number_of_pms": counts["number_of_pms"], + }) + + # Generate pie charts as base64 PNGs + def make_pie_chart(data: dict, title: str): + if not data: + return "" + fig = figure(figsize=(5, 5)) + ax = fig.add_subplot(111) + ax.pie( + data.values(), + labels=data.keys(), + autopct="%1.1f%%", + startangle=140, + ) + ax.set_title(title) + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight") + close(fig) + return b64encode(buf.getvalue()).decode("utf-8") + + repair_chart_b64 = make_pie_chart(repairs_by_meter, "Repairs by Meter") + pm_chart_b64 = make_pie_chart(pms_by_meter, "Preventative Maintenances by Meter") + + template = templates.get_template("maintenance_summary.html") + html = template.render( + from_month=from_month, + to_month=to_month, + repair_chart=repair_chart_b64, + pm_chart=pm_chart_b64, + table_rows=table_rows, + ) + + pdf_io = BytesIO() + HTML(string=html).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=maintenance_summary.pdf"}, + ) diff --git a/api/routes/meters.py b/api/routes/meters.py index 445299b3..c94357ea 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -172,56 +172,78 @@ def get_meters_locations( search_string: str = None, db: Session = Depends(get_db), ): - # Build the query statement based on query params - # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( - select(Meters).join(Wells, isouter=True).join(Locations, isouter=True) - ) - - # Ensure there are coordinates and meter is installed - query_statement = query_statement.where( - and_( - Locations.latitude.is_not(None), - Locations.longitude.is_not(None), - Meters.status_id == 1, + select( + Meters.id, + Meters.serial_number, + Wells.id.label("well_id"), + Wells.ra_number, + Wells.name, + Locations.id.label("location_id"), + Locations.latitude, + Locations.longitude, + Locations.trss, + ) + .select_from(Meters) + .join(Wells, Meters.well_id == Wells.id, isouter=True) + .join(Locations, Wells.location_id == Locations.id, isouter=True) + .where( + and_( + Locations.latitude.is_not(None), + Locations.longitude.is_not(None), + Meters.status_id == 1, # Only installed meters + ) ) ) if search_string: + ilike_term = f"%{search_string}%" query_statement = query_statement.where( or_( - Meters.serial_number.ilike(f"%{search_string}%"), - Wells.ra_number.ilike(f"%{search_string}%"), - Locations.trss.ilike(f"%{search_string}%"), + Meters.serial_number.ilike(ilike_term), + Wells.ra_number.ilike(ilike_term), + Locations.trss.ilike(ilike_term), ) ) - - meters = db.scalars(query_statement).all() - meter_ids = [meter.id for meter in meters] - - # Get the date of the last PM for each meter - pm_query = text('select max(timestamp_start) ' - 'as last_pm, meter_id from "MeterActivities" ' - 'where activity_type_id=4 and meter_id = ANY(:mids) ' - 'group by meter_id') - - pm_years = db.execute(pm_query,{'mids':meter_ids}).fetchall() - # Create a dictionary of meter_id to last_pm - pm_dict = {pm[1]: pm[0] for pm in pm_years} + result = db.execute(query_statement).fetchall() + meter_ids = [row.id for row in result] + + if not meter_ids: + return [] # Short-circuit if nothing matched + + # Query latest PMs for those meters + pm_query = text( + """ + SELECT MAX(timestamp_start) AS last_pm, meter_id + FROM "MeterActivities" + WHERE activity_type_id = 4 + AND meter_id = ANY(:mids) + GROUP BY meter_id + """ + ) + pm_years = db.execute(pm_query, {"mids": meter_ids}).fetchall() + pm_dict = {row.meter_id: row.last_pm for row in pm_years} - # Create a list of MeterMapDTO objects + # Map to DTOs manually for added performance meter_map_list = [] - for meter in meters: - # Find the last PM year for the meter - last_pm = pm_dict.get(meter.id, None) + for row in result: meter_map_list.append( meter_schemas.MeterMapDTO( - id=meter.id, - serial_number=meter.serial_number, - well=meter.well, - location=meter.well.location, - last_pm=last_pm + id=row.id, + serial_number=row.serial_number, + well={ + "id": row.well_id, + "ra_number": row.ra_number, + "name": row.name, + }, + location={ + "id": row.location_id, + "latitude": row.latitude, + "longitude": row.longitude, + "trss": row.trss, + }, + last_pm=pm_dict.get(row.id) ) ) diff --git a/api/routes/parts.py b/api/routes/parts.py index b66d8a4c..3eb9325c 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,20 +1,36 @@ -from fastapi import Depends, APIRouter, HTTPException +from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select -from typing import List - +from sqlalchemy import select, func +from typing import List, Union, Optional +from datetime import datetime +import calendar +from fastapi.responses import StreamingResponse +from weasyprint import HTML +from io import BytesIO from api.models.main_models import ( Parts, + PartsUsed, PartAssociation, PartTypeLU, Meters, MeterTypeLU, + meterRegisters, + MeterActivities, ) from api.schemas import part_schemas from api.session import get_db -from api.route_util import _get, _patch +from api.route_util import _get from api.enums import ScopedUser from sqlalchemy.exc import IntegrityError +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) part_router = APIRouter() @@ -25,8 +41,201 @@ dependencies=[Depends(ScopedUser.Read)], tags=["Parts"], ) -def get_parts(db: Session = Depends(get_db)): - return db.scalars(select(Parts).options(joinedload(Parts.part_type))).all() +def get_parts( + db: Session = Depends(get_db), + in_use: Optional[bool] = Query( + None, + description="Filter by in_use status" + ), +): + stmt = select(Parts).options(joinedload(Parts.part_type)) + + if in_use is not None: + stmt = stmt.where(Parts.in_use == in_use) + + return db.scalars(stmt).all() + + +@part_router.get( + "/parts/used", + tags=["Parts"], + dependencies=[Depends(ScopedUser.Read)], +) +def get_parts_used_summary( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + parts: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + # Parse and normalize start of "from" month + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + + # Determine end of "to" month + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + usage_subq = ( + db.query( + PartsUsed.c.part_id.label("used_part_id"), + func.count(PartsUsed.c.part_id).label("quantity") + ) + .join( + MeterActivities, + MeterActivities.id == PartsUsed.c.meter_activity_id + ) + .filter( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + func.coalesce(usage_subq.c.quantity, 0).label("quantity") + ) + .outerjoin(usage_subq, Parts.id == usage_subq.c.used_part_id) + .filter(Parts.id.in_(parts)) + .order_by(Parts.part_number) + ) + + results = [] + for row in query.all(): + price = row.price or 0 + total = price * row.quantity + results.append({ + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": row.quantity, + "total": total, + }) + + return results + + +@part_router.get( + "/parts/used/pdf", + tags=["Parts"], + dependencies=[Depends(ScopedUser.Read)], +) +def download_parts_used_pdf( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + parts: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + usage_subq = ( + db.query( + PartsUsed.c.part_id.label("used_part_id"), + func.count(PartsUsed.c.part_id).label("quantity") + ) + .join( + MeterActivities, + MeterActivities.id == PartsUsed.c.meter_activity_id + ) + .filter( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + func.coalesce(usage_subq.c.quantity, 0).label("quantity") + ) + .outerjoin(usage_subq, Parts.id == usage_subq.c.used_part_id) + .filter(Parts.id.in_(parts)) + .order_by(Parts.part_number) + ) + + results = [] + running_total = 0.0 + for row in query.all(): + price = row.price or 0 + quantity = row.quantity or 0 + total = price * quantity + running_total += total + results.append({ + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": quantity, + "total": total, + "running_total": running_total, + }) + + template = templates.get_template("parts_used_report.html") + html_content = template.render( + rows=results, + from_month=from_month, + to_month=to_month + ) + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=parts_used_report.pdf" + }, + ) @part_router.get( @@ -41,12 +250,12 @@ def get_part_types(db: Session = Depends(get_db)): @part_router.get( "/part", - response_model=part_schemas.Part, + response_model=Union[part_schemas.Part, part_schemas.Register], dependencies=[Depends(ScopedUser.Read)], tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - return db.scalars( + selected_part = db.scalars( select(Parts) .where(Parts.id == part_id) .options( @@ -55,6 +264,27 @@ def get_part(part_id: int, db: Session = Depends(get_db)): ) ).first() + # Create the part_schemas.Part instance + returned_part = part_schemas.Part.model_validate(selected_part) + + # If part_type is a Register, we need to load the register details + if selected_part and selected_part.part_type.name == "Register": + register_details = db.scalars( + select(meterRegisters).where( + meterRegisters.part_id == selected_part.id + ) + ).first() + + register_details = part_schemas.Register.register_details.model_validate(register_details) + + # Update the returned_part to include register details + returned_part = part_schemas.Register( + **returned_part.model_dump(exclude_unset=True), + register_settings=register_details + ) + + return returned_part + @part_router.patch( "/part", @@ -66,6 +296,8 @@ def update_part(updated_part: part_schemas.Part, db: Session = Depends(get_db)): # Update the part (this won't include secondary attributes like associations) part_db = _get(db, Parts, updated_part.id) for k, v in updated_part.model_dump(exclude_unset=True).items(): + if k in ["part_type", "meter_types"]: + continue try: setattr(part_db, k, v) except AttributeError as e: @@ -115,6 +347,7 @@ def create_part(new_part: part_schemas.Part, db: Session = Depends(get_db)): note=new_part.note, in_use=new_part.in_use, commonly_used=new_part.commonly_used, + price=new_part.price, ) try: diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 7cd465d4..10baa025 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -1,15 +1,37 @@ -from typing import List +from typing import List, Optional from datetime import datetime +import calendar +import re -from fastapi import Depends, APIRouter, Query +from fastapi import Depends, APIRouter, Query, HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload from sqlalchemy import select, and_ +from weasyprint import HTML +from io import BytesIO +from collections import defaultdict +from matplotlib.pyplot import figure, close +from base64 import b64encode + from api.schemas import well_schemas from api.models.main_models import WellMeasurements, ObservedPropertyTypeLU, Units, Wells from api.session import get_db from api.enums import ScopedUser +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +import matplotlib +matplotlib.use("Agg") # Force non-GUI backend + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) + well_measurement_router = APIRouter() @@ -50,154 +72,397 @@ def add_waterlevel( response_model=List[well_schemas.WellMeasurementDTO], tags=["WaterLevels"], ) -def read_waterlevels(well_id: int = None, db: Session = Depends(get_db)): - return db.scalars( - select(WellMeasurements) - .options(joinedload(WellMeasurements.submitting_user)) - .join(ObservedPropertyTypeLU) - .where( - and_( - ObservedPropertyTypeLU.name == "Depth to water", - WellMeasurements.well_id == well_id, +def read_waterlevels( + well_ids: List[int] = Query(..., description="One or more well IDs"), + from_month: Optional[str] = Query(None, pattern=r"^$|^\d{4}-\d{2}$"), + to_month: Optional[str] = Query(None, pattern=r"^$|^\d{4}-\d{2}$"), + isAveragingAllWells: bool = Query(False), + isComparingTo1970Average: bool = Query(False), + comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), + db: Session = Depends(get_db), +): + MONITORING_USE_TYPE_ID = 11 + synthetic_id_counter = -1 + + def group_and_average(measurements, group_by_label: str): + grouped = defaultdict(list) + for m in measurements: + key = m.timestamp.strftime("%Y-%m" if group_by_label == "month" else "%Y-%m-%d") + grouped[key].append(m.value) + + result = [] + for time_str, values in sorted(grouped.items()): + dt = datetime.strptime(time_str, "%Y-%m" if group_by_label == "month" else "%Y-%m-%d") + avg_value = sum(values) / len(values) + nonlocal synthetic_id_counter + result.append(well_schemas.WellMeasurementDTO( + id=synthetic_id_counter, + timestamp=dt, + value=avg_value, + submitting_user={"full_name": "System"}, + well={"ra_number": "Average of wells"} + )) + synthetic_id_counter -= 1 + return result + + def get_measurements_by_ids(well_ids, start, end): + stmt = ( + select(WellMeasurements) + .options(joinedload(WellMeasurements.submitting_user), joinedload(WellMeasurements.well)) + .join(ObservedPropertyTypeLU) + .where( + and_( + ObservedPropertyTypeLU.name == "Depth to water", + WellMeasurements.well_id.in_(well_ids), + *( [WellMeasurements.timestamp >= start] if start else [] ), + *( [WellMeasurements.timestamp <= end] if end else [] ), + ) ) + .order_by(WellMeasurements.well_id, WellMeasurements.timestamp) ) - ).all() - -@well_measurement_router.patch( - "/waterlevels", - dependencies=[Depends(ScopedUser.Admin)], - response_model=well_schemas.WellMeasurement, - tags=["WaterLevels"], -) -def patch_waterlevel(waterlevel_patch: well_schemas.PatchWaterLevel, db: Session = Depends(get_db)): - # Find the measurement - well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == waterlevel_patch.levelmeasurement_id)).first() - ) - - # Update the fields, all are mandatory - well_measurement.submitting_user_id = waterlevel_patch.submitting_user_id - well_measurement.timestamp = waterlevel_patch.timestamp - well_measurement.value = waterlevel_patch.value - - db.commit() - - return well_measurement - -@well_measurement_router.delete( - "/waterlevels", - dependencies=[Depends(ScopedUser.Admin)], - tags=["WaterLevels"], -) -def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): - # Find the measurement - well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == waterlevel_id)).first() - ) - - db.delete(well_measurement) - db.commit() - - return True - - -# ----------------- Chloride Concentration ----------------- # + return db.scalars(stmt).all() + + # Helper: add a comparison average for any given year (same rules as 1970) + def add_year_average(year: int, label: str): + # Determine comparison window shape based on requested range size + if (to_date - from_date).days >= 365: + start = datetime(year, 1, 1) + end = datetime(year, 12, 31, 23, 59, 59) + else: + start = datetime(year, from_date.month, 1) + last_day = calendar.monthrange(year, to_date.month)[1] + end = datetime(year, to_date.month, last_day, 23, 59, 59) + + monitoring_ids = [ + row[0] for row in db.execute( + select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) + ).all() + ] + year_measurements = get_measurements_by_ids(monitoring_ids, start, end) + averaged = group_and_average(year_measurements, "month") # Always by month + for dto in averaged: + dto.well.ra_number = label + response_data.extend(averaged) + + # Parse dates + from_date, to_date = None, None + if from_month and to_month: + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + today = datetime.now() + if to_dt.year == today.year and to_dt.month == today.month: + to_date = today + else: + last_day = calendar.monthrange(to_dt.year, to_dt.month)[1] + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") + + if not well_ids and not isComparingTo1970Average and not comparisonYear: + return [] + + group_by = None + if from_month and to_month: + group_by = "month" if (to_date - from_date).days >= 365 else "day" + + response_data = [] + + # Averaged selection (if requested) + if isAveragingAllWells and well_ids: + current_measurements = get_measurements_by_ids(well_ids, from_date, to_date) + averaged = group_and_average(current_measurements, group_by) + response_data.extend(averaged) + + # Raw per-well (if not averaging) + if not isAveragingAllWells and well_ids: + response_data.extend(get_measurements_by_ids(well_ids, from_date, to_date)) + + # 1970 comparison (existing behavior) + if isComparingTo1970Average: + add_year_average(1970, "1970 Average") + + # Dynamic comparison year (NEW) + if comparisonYear: + try: + year_int = int(comparisonYear) + except ValueError: + raise HTTPException(status_code=400, detail="comparisonYear must be a 4-digit year") + + current_year = datetime.now().year + if year_int < 1900 or year_int > current_year: + raise HTTPException(status_code=400, detail=f"comparisonYear must be between 1900 and {current_year}") + + # Avoid duplicate if user asked for 1970 both ways + already_added_1970 = isComparingTo1970Average and year_int == 1970 + if not already_added_1970: + add_year_average(year_int, f"{year_int} Average") + + return response_data @well_measurement_router.get( - "/chlorides", + "/waterlevels/pdf", dependencies=[Depends(ScopedUser.Read)], - response_model=List[well_schemas.WellMeasurementDTO], - tags=["Chlorides"], + tags=["WaterLevels"], ) -def read_chlorides( - chloride_group_id: int = Query(..., description="Chloride group ID to filter by"), - db: Session = Depends(get_db) +def download_waterlevels_pdf( + well_ids: List[int] = Query(..., description="One or more well IDs"), + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + isAveragingAllWells: bool = Query(False), + isComparingTo1970Average: bool = Query(False), + comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), + db: Session = Depends(get_db), ): - chloride_concentration_group_id = 5 - - return db.scalars( - select(WellMeasurements) - .options( - joinedload(WellMeasurements.submitting_user), - joinedload(WellMeasurements.well) - ) - .join(Wells, Wells.id == WellMeasurements.well_id) - .where( - and_( - WellMeasurements.observed_property_id == chloride_concentration_group_id, - Wells.chloride_group_id == chloride_group_id + MONITORING_USE_TYPE_ID = 11 + synthetic_id_counter = -1 + + def group_and_average(measurements, group_by_label: str, ra_label: str): + from collections import defaultdict + grouped = defaultdict(list) + for m in measurements: + key = m.timestamp.strftime("%Y-%m" if group_by_label == "month" else "%Y-%m-%d") + grouped[key].append(m.value) + + result = [] + for time_str, values in sorted(grouped.items()): + dt = datetime.strptime(time_str, "%Y-%m" if group_by_label == "month" else "%Y-%m-%d") + avg_value = sum(values) / len(values) + nonlocal synthetic_id_counter + result.append({ + "id": synthetic_id_counter, + "timestamp": dt, + "value": avg_value, + "well_ra_number": ra_label, + }) + synthetic_id_counter -= 1 + return result + + def get_measurements_by_ids(well_ids, start, end): + stmt = ( + select(WellMeasurements) + .options(joinedload(WellMeasurements.submitting_user), joinedload(WellMeasurements.well)) + .join(ObservedPropertyTypeLU) + .where( + and_( + ObservedPropertyTypeLU.name == "Depth to water", + WellMeasurements.well_id.in_(well_ids), + WellMeasurements.timestamp >= start, + WellMeasurements.timestamp <= end, + ) ) + .order_by(WellMeasurements.well_id, WellMeasurements.timestamp) + ) + return db.scalars(stmt).all() + + # Parse dates + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + today = datetime.now() + if to_dt.year == today.year and to_dt.month == today.month: + to_date = today + else: + last_day = calendar.monthrange(to_dt.year, to_dt.month)[1] + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") + + # treat "" as not provided + comparisonYear = comparisonYear or None + + if not well_ids and not isComparingTo1970Average and not comparisonYear: + raise HTTPException(status_code=400, detail="well_ids is required") + + group_by = "month" if (to_date - from_date).days >= 365 else "day" + results = [] + + # Averaging for selected wells + if isAveragingAllWells and well_ids: + current_measurements = get_measurements_by_ids(well_ids, from_date, to_date) + results.extend(group_and_average(current_measurements, group_by, "Average of wells")) + + # Raw per-well data + if not isAveragingAllWells and well_ids: + raw = get_measurements_by_ids(well_ids, from_date, to_date) + for m in raw: + results.append({ + "id": m.id, + "timestamp": m.timestamp, + "value": m.value, + "well_ra_number": m.well.ra_number if m.well else "Unknown" + }) + + # Helper: add comparison average for any given year (same window rules as 1970) + def add_year_average(year: int, label: str): + if (to_date - from_date).days >= 365: + start = datetime(year, 1, 1) + end = datetime(year, 12, 31, 23, 59, 59) + else: + start = datetime(year, from_date.month, 1) + last_day = calendar.monthrange(year, to_date.month)[1] + end = datetime(year, to_date.month, last_day, 23, 59, 59) + + monitoring_ids = [row[0] for row in db.execute( + select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) + ).all()] + year_measurements = get_measurements_by_ids(monitoring_ids, start, end) + averaged = group_and_average(year_measurements, "month", label) # Always monthly for comparison + results.extend(averaged) + + # 1970 Comparison + if isComparingTo1970Average: + add_year_average(1970, "1970 Average") + + # Dynamic comparison year + if comparisonYear: + try: + year_int = int(comparisonYear) + except ValueError: + raise HTTPException(status_code=400, detail="comparisonYear must be a 4-digit year") + now_year = datetime.now().year + if year_int < 1900 or year_int > now_year: + raise HTTPException(status_code=400, detail=f"comparisonYear must be between 1900 and {now_year}") + + # avoid duplicate series if user chose 1970 in both mechanisms + if not (isComparingTo1970Average and year_int == 1970): + add_year_average(year_int, f"{year_int} Average") + + report_title = "ROSWELL ARTESIAN BASIN" + report_subtext = None + + if isAveragingAllWells: + num_wells = len(well_ids) + well_word = "WELL" if num_wells == 1 else "WELLS" + report_subtext = ( + f"MONTHLY AVERAGE WATER LEVEL WITHIN {num_wells} PVACD RECORDER {well_word}\n" + "AVERAGES TAKEN FROM STEEL TAPE MEASUREMENTS MADE\n" + "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" ) - ).all() - -@well_measurement_router.post( - "/chlorides", - dependencies=[Depends(ScopedUser.WellMeasurementWrite)], - response_model=well_schemas.ChlorideMeasurement, - tags=["Chlorides"], -) -def add_chloride_measurement( - chloride_measurement: well_schemas.WellMeasurement, - db: Session = Depends(get_db), -): - # Create a new chloride measurement as a WellMeasurement - well_measurement = WellMeasurements( - timestamp = chloride_measurement.timestamp, - value = chloride_measurement.value, - observed_property_id = 5, # Chloride Concentration - submitting_user_id = chloride_measurement.submitting_user_id, - unit_id = chloride_measurement.unit_id, - well_id = chloride_measurement.well_id + from_year = from_date.year if from_date else None + + def shift_year_safe(dt, new_year: int): + """Shift dt to new_year, handling Feb 29 / month-end safely.""" + try: + return dt.replace(year=new_year) + except ValueError: + last_day = calendar.monthrange(new_year, dt.month)[1] + return dt.replace(year=new_year, day=min(dt.day, last_day)) + + # Prepare data for table + chart (apply timeshift to comparison series) + rows = [] + data_by_well = defaultdict(list) + + # Precompute which series should be shifted (e.g., "1970 Average", "2021 Average") + shift_years = set() + if isComparingTo1970Average: + shift_years.add(1970) + if comparisonYear: + try: + shift_years.add(int(comparisonYear)) + except ValueError: + pass # already validated above; safe guard + + for record in results: + original_ts = record["timestamp"] + value = record["value"] + well_label = record["well_ra_number"] + + # Table rows keep original timestamp + rows.append({ + "timestamp": original_ts.strftime("%Y-%m-%d %H:%M"), + "depth_to_water": value, + "well_ra_number": well_label, + }) + + chart_ts = original_ts + # Detect labels like "1970 Average" or "2021 Average" and shift to from_year + if from_year: + m = re.match(r"^(\d{4}) Average$", well_label) + if m: + yr = int(m.group(1)) + if yr in shift_years: + chart_ts = shift_year_safe(original_ts, from_year) + + data_by_well[well_label].append((chart_ts, value)) + + def make_line_chart(data: dict, title: str): + if not data: + return "" + fig = figure(figsize=(10, 6)) + ax = fig.add_subplot(111) + for ra, measurements in data.items(): + sorted_measurements = sorted(measurements, key=lambda x: x[0]) + timestamps = [ts for ts, _ in sorted_measurements] + values = [val for _, val in sorted_measurements] + ax.plot(timestamps, values, label=ra, marker='o') + ax.set_title(title) + ax.set_xlabel("Time") + ax.set_ylabel("Depth to Water") + ax.invert_yaxis() + ax.legend() + fig.autofmt_xdate() + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight") + close(fig) + return b64encode(buf.getvalue()).decode("utf-8") + + chart_b64 = make_line_chart(data_by_well, "Depth of Water over Time") + html = templates.get_template("waterlevels_report.html").render( + from_month=from_month, + to_month=to_month, + observation_chart=chart_b64, + rows=rows, + report_title=report_title, + report_subtext=report_subtext, ) - db.add(well_measurement) - db.commit() + pdf_io = BytesIO() + HTML(string=html).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=waterlevels_report.pdf"}, + ) - return well_measurement @well_measurement_router.patch( - "/chlorides", - dependencies=[Depends(ScopedUser.WellMeasurementWrite)], + "/waterlevels", + dependencies=[Depends(ScopedUser.Admin)], response_model=well_schemas.WellMeasurement, - tags=["Chlorides"], + tags=["WaterLevels"], ) -def patch_chloride_measurement( - chloride_measurement_patch: well_schemas.PatchChlorideMeasurement, - db: Session = Depends(get_db), -): +def patch_waterlevel(waterlevel_patch: well_schemas.PatchWaterLevel, db: Session = Depends(get_db)): # Find the measurement well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_patch.id)).first() + db.scalars(select(WellMeasurements).where(WellMeasurements.id == waterlevel_patch.levelmeasurement_id)).first() ) # Update the fields, all are mandatory - well_measurement.submitting_user_id = chloride_measurement_patch.submitting_user_id - well_measurement.timestamp = chloride_measurement_patch.timestamp - well_measurement.value = chloride_measurement_patch.value - well_measurement.unit_id = chloride_measurement_patch.unit_id - well_measurement.well_id = chloride_measurement_patch.well_id + well_measurement.submitting_user_id = waterlevel_patch.submitting_user_id + well_measurement.timestamp = waterlevel_patch.timestamp + well_measurement.value = waterlevel_patch.value db.commit() return well_measurement @well_measurement_router.delete( - "/chlorides", + "/waterlevels", dependencies=[Depends(ScopedUser.Admin)], - tags=["Chlorides"], + tags=["WaterLevels"], ) -def delete_chloride_measurement(chloride_measurement_id: int, db: Session = Depends(get_db)): +def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): # Find the measurement well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_id)).first() + db.scalars(select(WellMeasurements).where(WellMeasurements.id == waterlevel_id)).first() ) db.delete(well_measurement) db.commit() return True - - - diff --git a/api/routes/wells.py b/api/routes/wells.py index e7f9ade2..e9be8421 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -1,6 +1,6 @@ -from typing import List +from typing import List, Optional -from fastapi import Depends, APIRouter, HTTPException +from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy import or_, select, desc, text from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError @@ -64,6 +64,7 @@ def get_wells( sort_by: WellSortByField = WellSortByField.Name, sort_direction: SortDirection = SortDirection.Ascending, has_chloride_group: bool = None, + chloride_group_id: Optional[str] = Query(None, pattern=r"^$|^\d+$"), db: Session = Depends(get_db), ): def sort_by_field_to_schema_field(name: WellSortByField): @@ -104,6 +105,12 @@ def sort_by_field_to_schema_field(name: WellSortByField): if has_chloride_group is not None: query_statement = query_statement.where(Wells.chloride_group_id.isnot(None)) + if chloride_group_id: + query_statement = query_statement.where( + Wells.chloride_group_id == int(chloride_group_id) + ) + + if sort_by: schema_field_name = sort_by_field_to_schema_field(sort_by) @@ -163,7 +170,7 @@ def update_well( try: db.add(well_to_patch) db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException(status_code=409, detail="RA number already exists") # Get updated model with relationships @@ -214,7 +221,7 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g db.commit() db.refresh(new_well_model) - except IntegrityError as e: + except IntegrityError as _e: db.rollback() db.delete(new_location_model) db.commit() @@ -230,10 +237,6 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g return new_well_model - -# Get List of well for MapView -# Get search for well similar to /well but no pagination and only for installed well -# Returns all installed well with a location when search is None @well_router.get( "/well_locations", dependencies=[Depends(ScopedUser.Read)], @@ -242,13 +245,20 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g ) def get_wells_locations( search_string: str = None, + has_chloride_group: bool = None, + limit: int = 500, + offset: int = 0, db: Session = Depends(get_db), ): - # Build the query statement based on query params - # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( select(Wells) - .options(joinedload(Wells.location), joinedload(Wells.use_type)) + .options( + joinedload(Wells.location), + joinedload(Wells.use_type), + ) + .where( + Wells.location_id.isnot(None) + ) ) if search_string: @@ -261,11 +271,11 @@ def get_wells_locations( ) ) + if has_chloride_group is not None: + query_statement = query_statement.where(Wells.chloride_group_id.isnot(None)) - return db.scalars(query_statement).all() - + return db.scalars(query_statement.offset(offset).limit(limit)).all() -# End @well_router.get( "/well", @@ -340,40 +350,3 @@ def merge_well(well: well_schemas.SubmitWellMerge, db: Session = Depends(get_db) return True -@well_router.get( - "/chloride_groups", - dependencies=[Depends(ScopedUser.Read)], - response_model=List[well_schemas.ChlorideGroupResponse], - tags=["Chlorides"], -) -def get_chloride_groups( - sort_direction: SortDirection = SortDirection.Ascending, - db: Session = Depends(get_db), -): - query = ( - select(Wells) - .options(joinedload(Wells.location), joinedload(Wells.use_type)) - .join(Locations, isouter=True) - .join(WellUseLU, isouter=True) - .where(Wells.chloride_group_id.isnot(None)) - ) - - if sort_direction == SortDirection.Ascending: - query = query.order_by(Wells.chloride_group_id.asc()) - else: - query = query.order_by(Wells.chloride_group_id.desc()) - - wells = db.scalars(query).all() - - groups = {} - for well in wells: - group_id = well.chloride_group_id - if group_id not in groups: - groups[group_id] = [] - if well.ra_number: - groups[group_id].append(well.ra_number) - - return [ - {"id": group_id, "names": sorted(names)} - for group_id, names in groups.items() - ] diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 0243bb04..1f147c1a 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -15,12 +15,29 @@ class Part(ORMBase): note: str | None = None in_use: bool commonly_used: bool - + price: float | None = None part_type_id: int - part_type: PartTypeLU | None = None + part_type: PartTypeLU | None = None meter_types: list[MeterTypeLU] | None = None +class Register(Part): + ''' + Adds on register specific fields to the Part model. + Note: There is also a MeterRegister schema that is used on the Meters view. I might want + to merge these two in the future, but for now they are separate. + ''' + class register_details(ORMBase): + brand: str + meter_size: float + ratio: str + dial_units_id: int | None = None + totalizer_units_id: int | None = None + number_of_digits: int | None = None + multiplier: float | None = None + + register_settings: register_details + class PartUsed(ORMBase): part_id: int diff --git a/api/templates/chlorides_report.html b/api/templates/chlorides_report.html new file mode 100644 index 00000000..1e15688e --- /dev/null +++ b/api/templates/chlorides_report.html @@ -0,0 +1,73 @@ + + + + + + + +

Chloride Report

+

+ From: {{ from_month or "All Data" }} +    + To: {{ to_month or "All Data" }} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RegionMinMaxAverage
North{{ report.north.min }}{{ report.north.max }}{{ "%.2f"|format(report.north.avg or 0) }}
South{{ report.south.min }}{{ report.south.max }}{{ "%.2f"|format(report.south.avg or 0) }}
East{{ report.east.min }}{{ report.east.max }}{{ "%.2f"|format(report.east.avg or 0) }}
West{{ report.west.min }}{{ report.west.max }}{{ "%.2f"|format(report.west.avg or 0) }}
+ + + \ No newline at end of file diff --git a/api/templates/maintenance_summary.html b/api/templates/maintenance_summary.html new file mode 100644 index 00000000..8bc44978 --- /dev/null +++ b/api/templates/maintenance_summary.html @@ -0,0 +1,74 @@ + + + + + +

Maintenance Summary

+

+ From: {{ from_month }}    + To: {{ to_month }} +

+ + {% if repair_chart %} +
+

Repairs by Meter

+ +
+ {% endif %} {% if pm_chart %} +
+

Preventative Maintenance by Meter

+ +
+ {% endif %} + +

Detailed Activity Table

+ + + + + + + + + + + + {% for row in table_rows %} + + + + + + + + {% endfor %} + +
Date / TimeTechnicianMeterNumber of RepairsNumber of Preventative Maintenances
{{ row.date_time }}{{ row.technician }}{{ row.meter }}{{ row.number_of_repairs }}{{ row.number_of_pms }}
+ + diff --git a/api/templates/parts_used_report.html b/api/templates/parts_used_report.html new file mode 100644 index 00000000..d08ba4c4 --- /dev/null +++ b/api/templates/parts_used_report.html @@ -0,0 +1,56 @@ + + + + + +

Parts Usage Report

+

+ From: + {{ from_month }}   + To: + {{ to_month }} +

+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
+ + diff --git a/api/templates/waterlevels_report.html b/api/templates/waterlevels_report.html new file mode 100644 index 00000000..f663315e --- /dev/null +++ b/api/templates/waterlevels_report.html @@ -0,0 +1,85 @@ + + + + + + + +

{{ report_title }}

+ + {% if report_subtext %} +

+ {{ report_subtext }} +

+ {% endif %} + +

+ From: {{ from_month }}    + To: {{ to_month }} +

+ + {% if observation_chart %} +
+

Depth of Water over Time

+ +
+ {% endif %} + +

Water Level Measurements

+ + + + + + + + + + {% for row in rows %} + + + + + + {% endfor %} + +
Date / TimeDepth to Water (ft)Well
{{ row.timestamp }}{{ row.depth_to_water }}{{ row.well_ra_number }}
+ + + \ No newline at end of file diff --git a/backup.sh b/backup.sh new file mode 100644 index 00000000..33cafe1f --- /dev/null +++ b/backup.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +# Load ENV variables +API="${API:-http://localhost:8000}" +USERNAME="${USERNAME:?Missing USERNAME env}" +PASSWORD="${PASSWORD:?Missing PASSWORD env}" + +# Get token +TOKEN=$(curl -s -X POST "$API/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=$USERNAME&password=$PASSWORD" \ + | jq -r .access_token) + +# Call backup endpoint +curl -s -X BACKUP "$API/backup-db/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" diff --git a/docker-compose.development.yml b/docker-compose.development.yml index e9f13625..d34cfc80 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -1,14 +1,12 @@ # Docker Compose for development server -# Uses remote database - see configuration in api/.env_production +# Uses remote database - see configuration in api/.env_devserver version: "3.9" services: frontend: build: context: ./frontend - dockerfile: ./Dockerfile_production - environment: - - DEVSERVER=True + dockerfile: ./Dockerfile env_file: - ./frontend/.env.devserver ports: @@ -31,11 +29,18 @@ services: build: context: ./api dockerfile: ./Dockerfile - command: bash -c " - uvicorn api.main:app - --host 0.0.0.0 - --proxy-headers --root-path /api/v1 - " + working_dir: /app + environment: + - GCP_BUCKET_NAME=pvacd + - GCP_BACKUP_PREFIX=pre-prod-db-backups + - BACKUP_RETENTION_DAYS=14 + - APPDB_ENV=.env_devserver + command: > + uvicorn api.main:app + --host 0.0.0.0 + --port 8000 + --proxy-headers + --root-path /api/v1 ports: - "8000:8000" volumes: @@ -71,17 +76,15 @@ services: - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - # for production deployment testing make sure the next line is uncommented. Comment out once you know all app components are working - - "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - "--certificatesresolvers.myresolver.acme.email=newmexicowaterdata@gmail.com" - - "--certificatesresolvers.myresolver.acme.storage=acme.json" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" -# - "--traefik.http.middlewares.force-secure.redirectscheme.scheme=https" -# - "--traefik.http.middlewares.force-secure.redirectscheme.permanent=true" + ports: - "80:80" - "443:443" - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" + - ./letsencrypt:/letsencrypt diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 14668897..c45ec0de 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -6,11 +6,11 @@ services: frontend: build: context: ./frontend - dockerfile: ./Dockerfile_production - ports: - - "5173:80" + dockerfile: ./Dockerfile env_file: - ./frontend/.env.production + ports: + - "5173:80" depends_on: - api - traefik @@ -29,11 +29,18 @@ services: build: context: ./api dockerfile: ./Dockerfile - command: bash -c " - uvicorn api.main:app - --host 0.0.0.0 - --proxy-headers --root-path /api/v1 - " + working_dir: /app + environment: + - GCP_BUCKET_NAME=pvacd + - GCP_BACKUP_PREFIX=prod-db-backups + - BACKUP_RETENTION_DAYS=90 + - APPDB_ENV=.env_production + command: > + uvicorn api.main:app + --host 0.0.0.0 + --port 8000 + --proxy-headers + --root-path /api/v1 ports: - "8000:8000" volumes: @@ -69,19 +76,16 @@ services: - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - # for production deployment testing make sure the next line is uncommented. Comment out once you know all app components are working -# - "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - "--certificatesresolvers.myresolver.acme.email=newmexicowaterdata@gmail.com" - - "--certificatesresolvers.myresolver.acme.storage=acme.json" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" -# - "--traefik.http.middlewares.force-secure.redirectscheme.scheme=https" -# - "--traefik.http.middlewares.force-secure.redirectscheme.permanent=true" + ports: - "80:80" - "443:443" - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" - - \ No newline at end of file + - ./letsencrypt:/letsencrypt + diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6ff9fd06..d54c8ff0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,15 +1,18 @@ -#CC Testing -FROM node:latest +# stage1 - build react app first +FROM node:22-alpine as build +WORKDIR /app +ENV PATH /app/node_modules/.bin:$PATH +COPY ./package.json /app/ +COPY ./package-lock.json /app/ +RUN npm ci +COPY . /app +RUN npm run build -WORKDIR /home/node/app - -#Copy requirements and install before copying everything else -#This is beneficial for when the container is rebuilt -#https://stackoverflow.com/questions/63691203/how-do-i-make-a-react-app-in-docker-compose-container-is-exiting-after-build-st -COPY package*.json ./ -RUN npm install - -COPY . . - -#EXPOSE 3000 +# stage 2 - build the final image and copy the react build files +FROM nginx:1.23.2-alpine +COPY --from=build /app/dist /usr/share/nginx/html +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx/nginx.conf /etc/nginx/conf.d +#EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/Dockerfile_dev_old b/frontend/Dockerfile_dev_old deleted file mode 100644 index 8e60dfbd..00000000 --- a/frontend/Dockerfile_dev_old +++ /dev/null @@ -1,25 +0,0 @@ -#FROM node:latest -#WORKDIR /app -#COPY . . -##RUN npm install -##COPY ./package.json ./ -##COPY ./package-lock.json ./ -##RUN npm ci -##RUN npm run build -#RUN yarn -##COPY . ./ -#RUN yarn build -##RUN #yarn install --frozen-lockfile -##RUN npm run build -#RUN npm install -g serve -#CMD serve -s build - -# stage1 - build react app first -FROM node:latest as build -WORKDIR /app -ENV PATH /app/node_modules/.bin:$PATH -COPY ./package.json /app/ -COPY ./yarn.lock /app/ -RUN yarn -COPY . /app -CMD yarn start \ No newline at end of file diff --git a/frontend/Dockerfile_old b/frontend/Dockerfile_old deleted file mode 100644 index debc43b4..00000000 --- a/frontend/Dockerfile_old +++ /dev/null @@ -1,17 +0,0 @@ -# stage1 - build react app first -FROM node:19-alpine as build -WORKDIR /app -ENV PATH /app/node_modules/.bin:$PATH -COPY ./package.json /app/ -COPY ./yarn.lock /app/ -RUN yarn -COPY . /app -RUN yarn build - -# stage 2 - build the final image and copy the react build files -FROM nginx:1.23.2-alpine -COPY --from=build /app/build /usr/share/nginx/html -RUN rm /etc/nginx/conf.d/default.conf -COPY nginx/nginx.conf /etc/nginx/conf.d -#EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/Dockerfile_production b/frontend/Dockerfile_production deleted file mode 100644 index d54c8ff0..00000000 --- a/frontend/Dockerfile_production +++ /dev/null @@ -1,18 +0,0 @@ -# stage1 - build react app first -FROM node:22-alpine as build -WORKDIR /app -ENV PATH /app/node_modules/.bin:$PATH -COPY ./package.json /app/ -COPY ./package-lock.json /app/ -RUN npm ci -COPY . /app -RUN npm run build - - -# stage 2 - build the final image and copy the react build files -FROM nginx:1.23.2-alpine -COPY --from=build /app/dist /usr/share/nginx/html -RUN rm /etc/nginx/conf.d/default.conf -COPY nginx/nginx.conf /etc/nginx/conf.d -#EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html index 6fded169..20c9a847 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,46 +1,66 @@ - - - - - - - - - - - - - - - - - - - - - - Meter Manager DB - - - -
- - - + + + + + + + + + + + + + + + + + + + + + + + Meter Manager + + + + + +
+ + + + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7819933..9ea3c7e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,11 +9,13 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.10.6", "@mui/material": "^5.15.14", + "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", "dayjs": "^1.11.9", @@ -132,13 +134,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -197,6 +196,23 @@ "node": ">=6.9.0" } }, + "node_modules/@changey/react-leaflet-markercluster": { + "version": "4.0.0-rc1", + "resolved": "https://registry.npmjs.org/@changey/react-leaflet-markercluster/-/react-leaflet-markercluster-4.0.0-rc1.tgz", + "integrity": "sha512-gS1lEQiQwyeI6Y6Wuxuqqffwywm7giQw4tbcqtJP8zyT5bc3AzW2/EVJGwWORYo/PLDdDnvOrpI+lUJy2UA5MQ==", + "license": "MIT", + "dependencies": { + "@react-leaflet/core": "^2.0.0", + "leaflet": "^1.8.0", + "leaflet.markercluster": "^1.5.3", + "react-leaflet": "^4.0.0" + }, + "peerDependencies": { + "leaflet": "^1.8.0", + "leaflet.markercluster": "^1.5.3", + "react-leaflet": "^4.0.0" + } + }, "node_modules/@choojs/findup": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", @@ -1495,10 +1511,13 @@ } }, "node_modules/@mui/types": { - "version": "7.2.21", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", - "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", + "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3" + }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1538,6 +1557,172 @@ } } }, + "node_modules/@mui/x-charts": { + "version": "8.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.0.0-beta.3.tgz", + "integrity": "sha512-3SYH5DoMv/xL0gGo7xKtuTu2GsNlgHCur7zalP7kWeIjTgCXib+ZUixGEMdfdyRcDEADkXWFssYw2QhsXA+rNg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.0", + "@mui/x-charts-vendor": "8.0.0-beta.3", + "@mui/x-internals": "8.0.0-beta.3", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1 || ^19.0.0", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.0.0-beta.3.tgz", + "integrity": "sha512-mcelNPzVYyrU8yVkW/CcTGw0doFLtSFj1Pw8q8LghvJW3rMJUeoHxU2WVOUU2+ha4sHSlEBCPwRZvJBJnoWyqA==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@types/d3-color": "^3.1.3", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-color": "^3.1.0", + "d3-delaunay": "^6.0.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1", + "delaunator": "^5.0.1", + "robust-predicates": "^3.0.2" + } + }, + "node_modules/@mui/x-charts-vendor/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mui/x-charts-vendor/node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@mui/x-charts-vendor/node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mui/x-charts-vendor/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mui/x-charts-vendor/node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@mui/x-charts/node_modules/@mui/utils": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz", + "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.3", + "@mui/types": "^7.4.6", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts/node_modules/@mui/x-internals": { + "version": "8.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.0.0-beta.3.tgz", + "integrity": "sha512-crbtLMWhI0sFXaZLknXPEGEaPLxpdIe8XAkJIr0HXD563TagGeyVk8lbNLoa5H3mVHWxmzNYiGUA4ns5Q6urQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/x-data-grid": { "version": "7.27.3", "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.27.3.tgz", @@ -2380,6 +2565,63 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2530,9 +2772,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { @@ -3301,6 +3543,12 @@ "node": ">= 0.6.0" } }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3996,6 +4244,18 @@ "node": ">=12" } }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dispatch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", @@ -4078,6 +4338,46 @@ "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", "license": "BSD-3-Clause" }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", @@ -4156,6 +4456,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-kerning": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", @@ -5551,6 +5860,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -5977,6 +6295,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7201,9 +7528,9 @@ } }, "node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", "license": "MIT" }, "node_modules/react-leaflet": { @@ -7344,12 +7671,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", @@ -7544,6 +7865,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.34.9", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", @@ -8511,9 +8838,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/frontend/package.json b/frontend/package.json index 25dc9cda..6650a1e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,13 @@ "preview": "vite preview" }, "dependencies": { + "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.10.6", "@mui/material": "^5.15.14", + "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", "dayjs": "^1.11.9", diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 84c3a82d..00000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,90 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.container { - display: flex; - margin-top: 10px; -} - -.link { - text-decoration: none; - color: inherit; -} - -.underlined { - text-decoration: underline; -} - -.custom-card-header-small { - display: flex; - flex-direction: row; - align-items: center; - color: white; - background: #292929; - box-shadow: "120px 120px 100px 120px rgba(0,0,0,0.2)"; - border-radius: 5px; - padding: 10px 10px 10px 14px; - margin: 0; - font-weight: 600; - font-size: 1rem; -} - -.custom-card-header { - display: flex; - flex-direction: row; - align-items: center; - color: white; - background: #292929; - box-shadow: "120px 120px 100px 120px rgba(0,0,0,0.2)"; - border-radius: 5px; - padding: 10px 10px 10px 14px; - margin: 0; - font-weight: 500; - font-size: 1.1rem; -} - -.custom-card-header span { - flex: 1; -} - -.custom-card-header svg { - font-size: 1.3rem; - padding-bottom: 0px; - margin-right: 10px; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7197994a..24bdd003 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,84 +1,37 @@ -import "./App.css"; import { useEffect, useState } from "react"; -import { AuthProvider, useAuthUser } from "react-auth-kit"; +import { AuthProvider } from "react-auth-kit"; import { Route, BrowserRouter as Router, Routes, - useNavigate, } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "react-query"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; import { SnackbarProvider, enqueueSnackbar } from "notistack"; -import { Grid } from "@mui/material"; - -import MonitoringWellsView from "./views/MonitoringWells/MonitoringWellsView"; -import ActivitiesView from "./views/Activities/ActivitiesView"; -import MetersView from "./views/Meters/MetersView"; -import PartsView from "./views/Parts/PartsView"; -import UserManagementView from "./views/UserManagement/UserManagementView"; +import { + Home, + Login, + Settings, +} from './views' +import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; +import { ActivitiesView } from "./views/Activities/ActivitiesView"; +import { MetersView } from "./views/Meters/MetersView"; +import { PartsView } from "./views/Parts/PartsView"; +import { UserManagementView } from "./views/UserManagement/UserManagementView"; import WellManagementView from "./views/WellManagement/WellManagementView"; import WorkOrdersView from "./views/WorkOrders/WorkOrdersView"; +import { ChloridesView } from "./views/Chlorides/ChloridesView"; +import { ReportsView } from "./views/Reports"; +import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; +import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; +import { MaintenanceReportView } from "./views/Reports/Maintenance"; +import { PartsUsedReportView } from "./views/Reports/PartsUsed"; +import { BoardReportView } from "./views/Reports/Board"; +import { ChloridesReportView } from "./views/Reports/Chlorides"; +import { AppLayout } from "./AppLayout"; -import Sidenav from "./sidenav"; -import Home from "./Home"; -import Topbar from "./components/Topbar"; -import Login from "./login"; -import { SecurityScope } from "./interfaces"; -import ChloridesView from "./views/Chlorides/ChloridesView"; - -// A wrapper that handles checking that the user is logged in and has any necessary scopes -function AppLayout({ - pageComponent, - requiredScopes = null, - setErrorMessage = null, -}: any) { - const authUser = useAuthUser(); - const navigate = useNavigate(); - - const isLoggedIn = authUser() != null; - const userScopes = authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string, - ); - const hasScopes = - requiredScopes == null - ? true - : requiredScopes?.every((scope: string) => userScopes?.includes(scope)); - - useEffect(() => { - if (!isLoggedIn) { - if (setErrorMessage) setErrorMessage("You must login to view pages."); - navigate("/"); - } else if (!hasScopes) { - if (setErrorMessage) - setErrorMessage( - "You do not have sufficient permissions to view this page.", - ); - navigate("/home"); - } - }, [authUser()]); - - if (isLoggedIn && hasScopes) - return ( - - - - - - - - - - {pageComponent} - - - - ); - return null; -} - -export default function App() { +export const App = () => { const queryClient = new QueryClient(); // Showing messages between navigation (eg: accessing forbidden page, accessing while not logged in) results in duplicated snackbars, this is a workaround @@ -104,12 +57,28 @@ export default function App() { > - } /> } + requiredScopes={[]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={[]} + setErrorMessage={setErrorMessage} + /> + } /> + } requiredScopes={["read"]} setErrorMessage={setErrorMessage} /> @@ -145,6 +114,76 @@ export default function App() { /> } /> + } + 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} + /> + } + /> ); -} +}; diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx new file mode 100644 index 00000000..98281aaf --- /dev/null +++ b/frontend/src/AppLayout.tsx @@ -0,0 +1,98 @@ +import { useAuthUser } from "react-auth-kit"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +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) + ); + + 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 ( + + setDrawerOpen(!drawerOpen)} + /> + setDrawerOpen(false)} + /> + + {pageComponent} + + + ); +}; + diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx deleted file mode 100644 index 57a6d45b..00000000 --- a/frontend/src/Home.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {Component} from "react"; -import {Box, Card, CardContent, CardHeader} from "@mui/material"; -import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; -import pvacd_logo from './img/pvacd_logo.png' -import meter_field from './img/meter_field.jpg' -import meter_storage from './img/meter_storage.jpg' - -class Home extends Component{ - render() { - - const versionHistory = [ - "V0.1.52 - Deploy chlorides for admin testing", - "V0.1.51 - Improved monitoring well page", - "V0.1.50 - Fixed wells map bug and update register if part used", - "V0.1.49 - Added outside recorder wells to monitoring page", - "V0.1.48 - Changed well owner to be meter water users", - "V0.1.47 - Add TRSS grids to meter map and fixed meter register save bug", - "V0.1.46 - Change how data is displayed in Wells table", - "V0.1.45 - Color code meter markers on map by last PM", - "V0.1.44 - Fix bug in continuous monitoring well data and added data to OSE endpoint", - "V0.1.43 - Fix navigation from work orders to activity, add OSE endpoint for \"data issues\"", - "V0.1.42 - Fix pagination, add 'uninstall and hold'", - "V0.1.41 - Add UI for water source on wells and some other minor changes" - ] - - return ( - -

Meter Manager Home

- - - PVACD Meter Manager Info - - - } - sx={{mb: 2, pb: 0}} - /> - - - - -

Version History

-
    - {versionHistory.map((version) => ( -
  • {version}
  • - ))} -
- - - - -
-
-
-
-
- ); - } -} - -export default Home; diff --git a/frontend/src/assets/leaflet/marker-icon-red.png b/frontend/src/assets/leaflet/marker-icon-red.png new file mode 100644 index 00000000..3e64e06d Binary files /dev/null and b/frontend/src/assets/leaflet/marker-icon-red.png differ diff --git a/frontend/src/components/BackgroundBox.tsx b/frontend/src/components/BackgroundBox.tsx new file mode 100644 index 00000000..a2c5bd4f --- /dev/null +++ b/frontend/src/components/BackgroundBox.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Box, BoxProps } from "@mui/material"; + +export const BackgroundBox: React.FC = ({ + children, + sx, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/CustomCardHeader.tsx b/frontend/src/components/CustomCardHeader.tsx new file mode 100644 index 00000000..38e24031 --- /dev/null +++ b/frontend/src/components/CustomCardHeader.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { + CardHeader, + CardHeaderProps, + SvgIconProps, + Box, + Typography, +} from "@mui/material"; + +type CustomCardHeaderProps = Omit & { + title?: string; + icon?: React.ComponentType; +}; + +export const CustomCardHeader: React.FC = ({ + title, + icon: Icon = null, + sx, + ...rest +}) => { + return ( + + + {title} + + {Icon && ( + + )} + + } + sx={{ + mb: 0, + pb: 0, + ...sx, + }} + {...rest} + /> + ); +}; diff --git a/frontend/src/components/DirectionCard.tsx b/frontend/src/components/DirectionCard.tsx new file mode 100644 index 00000000..c046f287 --- /dev/null +++ b/frontend/src/components/DirectionCard.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, Divider, Stack, Typography } from '@mui/material'; +import { StatCell } from './StatCell' + +export const DirectionCard = ({ + title, + min, + avg, + max, +}: { + title: string; + min?: number; + avg?: number; + max?: number; +}) => { + return ( + + + + {title} + + + + + + + + + + ); +} diff --git a/frontend/src/components/Layers/OpenStreetMapLayer.tsx b/frontend/src/components/Layers/OpenStreetMapLayer.tsx new file mode 100644 index 00000000..d6dfaf91 --- /dev/null +++ b/frontend/src/components/Layers/OpenStreetMapLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet" + +export const OpenStreetMapLayer = () => ( + + + +) diff --git a/frontend/src/components/Layers/SatelliteLayer.tsx b/frontend/src/components/Layers/SatelliteLayer.tsx new file mode 100644 index 00000000..d22221ca --- /dev/null +++ b/frontend/src/components/Layers/SatelliteLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet" + +export const SatelliteLayer = () => ( + + + +) diff --git a/frontend/src/components/Layers/SoutheastGuideLayer.tsx b/frontend/src/components/Layers/SoutheastGuideLayer.tsx new file mode 100644 index 00000000..46ec12d6 --- /dev/null +++ b/frontend/src/components/Layers/SoutheastGuideLayer.tsx @@ -0,0 +1,93 @@ +// SoutheastGuideLayer.tsx +import * as L from "leaflet"; +import { LayersControl, Pane, FeatureGroup, Rectangle, Polyline, Marker } from "react-leaflet"; + +const NM_LAT_MIN = 33.12500; +const NM_LAT_MAX = 34.12500; +const NM_LON_MIN = -105.25000; +const NM_LON_MAX = -104.25000; + +const MID_LAT = (NM_LAT_MIN + NM_LAT_MAX) / 2; +const MID_LON = (NM_LON_MIN + NM_LON_MAX) / 2; + +// Southeast quadrant bounds +const SE_LAT_MIN = NM_LAT_MIN; +const SE_LAT_MAX = MID_LAT; +const SE_LON_MIN = MID_LON; +const SE_LON_MAX = NM_LON_MAX; + +const SE_MID_LAT = (SE_LAT_MIN + SE_LAT_MAX) / 2; +const SE_MID_LON = (SE_LON_MIN + SE_LON_MAX) / 2; + +// Helpers +const rectBounds: [[number, number], [number, number]] = [ + [SE_LAT_MIN, SE_LON_MIN], + [SE_LAT_MAX, SE_LON_MAX], +]; + +const horizLine = [ + [SE_MID_LAT, SE_LON_MIN], + [SE_MID_LAT, SE_LON_MAX], +] as [number, number][]; + +const vertLine = [ + [SE_LAT_MIN, SE_MID_LON], + [SE_LAT_MAX, SE_MID_LON], +] as [number, number][]; + +const labelIcon = (text: string) => + L.divIcon({ + className: "", + html: `
${text}
`, + }); + +export const SoutheastGuideLayer = () => +( + + {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} + + + {/* SE quadrant rectangle */} + + + {/* Midlines */} + + + + {/* Labels (placed toward the center of each half) */} + + + + + + {/* Optional: center dot where lines cross */} + {/* ' })} /> */} + + + +); diff --git a/frontend/src/components/Layers/index.ts b/frontend/src/components/Layers/index.ts new file mode 100644 index 00000000..a4be50d8 --- /dev/null +++ b/frontend/src/components/Layers/index.ts @@ -0,0 +1,4 @@ +export * from './SoutheastGuideLayer' +export * from './SatelliteLayer' +export * from './OpenStreetMapLayer' + diff --git a/frontend/src/components/MeterMapColorLegend.tsx b/frontend/src/components/MeterMapColorLegend.tsx new file mode 100644 index 00000000..fad8515b --- /dev/null +++ b/frontend/src/components/MeterMapColorLegend.tsx @@ -0,0 +1,57 @@ +import { useEffect } from "react"; +import { useLeafletContext } from "@react-leaflet/core"; +import L from "leaflet"; +import { PM_COLORS } from "../constants"; + +export const MeterMapColorLegend = () => { + const context = useLeafletContext(); + + useEffect(() => { + const legend = new L.Control({ position: "bottomleft" }); + + legend.onAdd = function() { + const div = L.DomUtil.create("div", "info legend"); + + div.style.background = "white"; + div.style.padding = "10px"; + div.style.borderRadius = "8px"; + div.style.boxShadow = "0 2px 6px rgba(0,0,0,0.3)"; + div.style.fontSize = "14px"; + div.style.lineHeight = "18px"; + + const title = L.DomUtil.create("h4", "", div); + title.textContent = "PM Season"; + title.style.margin = "0 0 8px 0"; + + for (const season in PM_COLORS) { + const row = L.DomUtil.create("div", "", div); + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.marginBottom = "6px"; + + const colorBox = L.DomUtil.create("div", "", row); + colorBox.style.width = "20px"; + colorBox.style.height = "20px"; + colorBox.style.background = PM_COLORS[season]; + colorBox.style.marginRight = "8px"; + colorBox.style.border = "1px solid #ccc"; + colorBox.style.borderRadius = "4px"; + + const label = L.DomUtil.create("span", "", row); + label.textContent = season; + } + + return div; + }; + + const container = context.map; + container.addControl(legend); + + return () => { + container.removeControl(legend); + }; + }, [context.map]); + + return null; +} + diff --git a/frontend/src/components/MeterRegisterSelect.tsx b/frontend/src/components/MeterRegisterSelect.tsx index 3df27bc3..08e56657 100644 --- a/frontend/src/components/MeterRegisterSelect.tsx +++ b/frontend/src/components/MeterRegisterSelect.tsx @@ -42,7 +42,6 @@ export default function MeterRegisterSelect({ //Filter the register list based on the meter type useEffect(() => { if (meterType) { - console.log(meterType); setFilteredRegisterList( meterRegisterList.data?.filter( (register: MeterRegister) => @@ -57,8 +56,6 @@ export default function MeterRegisterSelect({ //Check if the selected register is in the filtered list, if not, set it to null useEffect(() => { - console.log(selectedRegister); - console.log(filteredRegisterList); if ( selectedRegister && !filteredRegisterList?.some( @@ -100,7 +97,7 @@ export default function MeterRegisterSelect({ )} - {childProps.error && ( + {childProps.error && childProps.helperText && ( {childProps.helperText} )} diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx new file mode 100644 index 00000000..830c2edd --- /dev/null +++ b/frontend/src/components/NavLink.tsx @@ -0,0 +1,58 @@ +import { Grid, SvgIconProps, Box, Typography } from "@mui/material"; +import TableViewIcon from "@mui/icons-material/TableView"; +import { Link, useLocation } from "react-router-dom"; + +export const NavLink = ({ + disabled = false, + route, + label, + Icon, +}: { + disabled?: boolean; + route: string; + label: string; + Icon?: React.ComponentType; +}) => { + const location = useLocation(); + const isActive = location.pathname === route; + + const content = ( + + {Icon ? ( + + ) : ( + + )} + {label} + + ); + + return ( + + {disabled ? ( + content + ) : ( + + {content} + + )} + + ); +}; diff --git a/frontend/src/components/NewMeasurementModal.tsx b/frontend/src/components/NewMeasurementModal.tsx index 4a9c17eb..5378ec7b 100644 --- a/frontend/src/components/NewMeasurementModal.tsx +++ b/frontend/src/components/NewMeasurementModal.tsx @@ -44,15 +44,18 @@ export function NewMeasurementModal({ // Sends user entered information to the parent through callback function onMeasurementSubmitted() { - const d = new Date( - Date.parse(date?.format() ?? Date()), - ).toLocaleDateString(); - const t = new Date( - Date.parse(time?.format() ?? Date()), - ).toLocaleTimeString(); + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); handleSubmitNewMeasurement({ - timestamp: new Date(Date.parse(d + " " + t)), + timestamp: combinedDateTime.toISOString(), value: value as number, submitting_user_id: selectedUserID as number, well_id: -1, // Set by parent diff --git a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx index 72a56c67..d474020e 100644 --- a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx +++ b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx @@ -8,38 +8,46 @@ const disabledInputStyle = { cursor: "default", }; -// React-Hook-Form controlled version of the autocomplete component export default function ControlledAutocomplete({ control, name, + options = [], + groupBy, + getOptionLabel, + isOptionEqualToValue, + multiple = false, ...childProps }: any) { return ( ( - x} // Disable filtering because backend already does this - isOptionEqualToValue={(a: any, b: any) => { - // Let any value be an option whether or not its in the list - const optionPresent = childProps.options.find( - (x: any) => x.id == b?.id, - ); - if (!optionPresent) { - childProps.options.push(b); - return true; - } - return a?.id == b?.id; - }} - onChange={(_, value) => field.onChange(value)} - /> - )} + defaultValue={multiple ? [] : null} + render={({ field }) => { + const { value, onChange, ...restField } = field; + + const safeValue = multiple + ? Array.isArray(value) + ? value + : [] + : value ?? null; + + return ( + onChange(newValue)} + sx={disabledInputStyle} + {...childProps} + /> + ); + }} /> ); } diff --git a/frontend/src/components/RHControlled/ControlledDatepicker.tsx b/frontend/src/components/RHControlled/ControlledDatepicker.tsx index b52e84b6..78055d85 100644 --- a/frontend/src/components/RHControlled/ControlledDatepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledDatepicker.tsx @@ -4,6 +4,7 @@ import { Controller } from "react-hook-form"; export default function ControlledDatepicker({ name, control, + size = "small", ...childProps }: any) { return ( @@ -13,7 +14,7 @@ export default function ControlledDatepicker({ render={({ field }) => ( )} diff --git a/frontend/src/components/RHControlled/ControlledSelect.tsx b/frontend/src/components/RHControlled/ControlledSelect.tsx index c8d7a47f..696cf9f8 100644 --- a/frontend/src/components/RHControlled/ControlledSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledSelect.tsx @@ -7,51 +7,82 @@ import { } from "@mui/material"; import { Controller } from "react-hook-form"; -export function ControlledSelect({ control, name, ...childProps }: any) { +export function ControlledSelect({ + control, + name, + size = "small", + multiple = false, + ...childProps +}: any) { return ( ( - - {childProps.label} - + isMultiple + ? childProps.options + .filter((opt: any) => selected.includes(opt.id)) + .map((opt: any) => childProps.getOptionLabel(opt)) + .join(", ") + : childProps.getOptionLabel( + childProps.options.find( + (opt: any) => opt.id === selected, + ) ?? {}, + ) + } + > + {childProps.options.map((option: any) => ( + + {childProps.getOptionLabel(option)} + + ))} + {childProps.value === "Loading..." && ( + Loading... + )} + + {childProps.error && ( + {childProps.error} )} - - {childProps.error && ( - - {childProps.error} - - )} - - )} + + ); + }} /> ); } diff --git a/frontend/src/components/RHControlled/index.ts b/frontend/src/components/RHControlled/index.ts new file mode 100644 index 00000000..718157aa --- /dev/null +++ b/frontend/src/components/RHControlled/index.ts @@ -0,0 +1,20 @@ +export * from './ControlledActivitySelect' +export * from './ControlledAutocomplete' +export * from './ControlledCheckbox' +export * from './ControlledDatepicker' +export * from './ControlledDMS' +export * from './ControlledMeterRegisterSelect' +export * from './ControlledMeterSelection' +export * from './ControlledMeterStatusTypeSelect' +export * from './ControlledMeterTypeSelect' +export * from './ControlledPartTypeSelect' +export * from './ControlledSelect' +export * from './ControlledTextbox' +export * from './ControlledTimepicker' +export * from './ControlledUserSelect' +export * from './ControlledWellSelection' +export * from './ControlledWorkOrderSelect' +export * from './NotesChipSelect' +export * from './NSPChipSelect' +export * from './PartsChipSelect' +export * from './ServicesChipSelect' diff --git a/frontend/src/components/RegionMeasurementModals.tsx b/frontend/src/components/RegionMeasurementModals.tsx index 3d975088..b85afb29 100644 --- a/frontend/src/components/RegionMeasurementModals.tsx +++ b/frontend/src/components/RegionMeasurementModals.tsx @@ -51,7 +51,7 @@ export const NewMeasurementModal = ({ Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups"], + queryKey: ["wells", "has_chloride_groups", region_id], queryFn: () => fetchWithAuth({ method: "GET", @@ -60,6 +60,7 @@ export const NewMeasurementModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, + chloride_group_id: region_id, limit: 100, }, }), @@ -75,17 +76,20 @@ export const NewMeasurementModal = ({ const [time, setTime] = useState(dayjs.utc()); function onMeasurementSubmitted() { - const d = new Date( - Date.parse(date?.format() ?? Date()), - ).toLocaleDateString(); - const t = new Date( - Date.parse(time?.format() ?? Date()), - ).toLocaleTimeString(); + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); handleSubmitNewMeasurement({ region_id: 0, // Set by parent well_id: selectedWellID as number, - timestamp: new Date(Date.parse(d + " " + t)), + timestamp: combinedDateTime.toISOString(), value: value as number, submitting_user_id: selectedUserID as number, }); @@ -120,7 +124,7 @@ export const NewMeasurementModal = ({ } }; -const WellSelection = ({ region_id }: { region_id: number }) => { + const WellSelection = ({ region_id }: { region_id: number }) => { return ( Well @@ -255,7 +259,7 @@ export const UpdateMeasurementModal = ({ Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups"], + queryKey: ["wells", "has_chloride_groups", region_id], queryFn: () => fetchWithAuth({ method: "GET", @@ -264,6 +268,7 @@ export const UpdateMeasurementModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, + chloride_group_id: region_id, limit: 100, }, }), diff --git a/frontend/src/components/StatCell.tsx b/frontend/src/components/StatCell.tsx new file mode 100644 index 00000000..3fd8f43a --- /dev/null +++ b/frontend/src/components/StatCell.tsx @@ -0,0 +1,13 @@ +import { Stack, Typography } from "@mui/material"; +import { formatNumberData } from "../utils"; + +export const StatCell = ({ label, value }: { label: string; value?: number }) => { + return ( + + + {label} + + {formatNumberData(value)} ppm + + ); +} diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 70b997ae..a8163ee8 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,93 +1,189 @@ -import { useState, useRef } from "react"; import { - Button, + AppBar, + Toolbar, + Typography, + IconButton, + Avatar, Menu, MenuItem, - Avatar, - Grid, - Typography, + Button, + Box, + Divider, } from "@mui/material"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useSignOut, useAuthUser } from "react-auth-kit"; +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"; -export default function Topbar() { - const location = useLocation(); +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 profileMenuRef = useRef(null); - const [isProfileMenuOpen, setProfileMenuOpen] = useState(false); + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; const fullSignOut = () => { navigate("/"); signOut(); }; - return ( - - - navigate("/home")} - > - Meter Manager - - + const renderRoleIcon = () => { + switch (role) { + case "Admin": + return ; + case "Technician": + return ; + default: + return ; + } + }; - {location.pathname !== "/" && ( - - + + + {!open ? + navigate("/")} + > + Meter Manager + + : null} + - setProfileMenuOpen(false)} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - transformOrigin={{ horizontal: "right", vertical: "top" }} - > - Logout - - - )} - + {isLoggedIn ? ( + + + + + + + Role: {role ?? "Unknown"} + + + + + { + navigate("/settings") + handleMenuClose() + }} + > + Settings + + { + fullSignOut() + handleMenuClose() + }}>Logout + + + ) + : ( + + )} + + ); } -const styles = { - container: { - zIndex: "100 !important", - justifyContent: "space-between", - backgroundColor: "white", - py: 1, - boxShadow: "3px 2px 5px -2px rgba(0,0,0,0.2)", - }, - logo: { - fontWeight: "bold", - fontSize: "32px", - color: "darkblue", - cursor: "pointer", - marginLeft: "10px", - }, - button: { - marginTop: "auto", - marginBottom: "auto", - }, - avatar: { - width: 32, - height: 32, - marginRight: 1, - }, -}; diff --git a/frontend/src/components/WellMapLegend.tsx b/frontend/src/components/WellMapLegend.tsx new file mode 100644 index 00000000..d6a5e8d3 --- /dev/null +++ b/frontend/src/components/WellMapLegend.tsx @@ -0,0 +1,59 @@ +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"; + +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 ( +
+
+ Well + Well +
+
+ Chloride Monitored Well + Chloride Monitored Well +
+
+ ); +}; + diff --git a/frontend/src/components/WellMeasurementModals.tsx b/frontend/src/components/WellMeasurementModals.tsx index e98f4a12..e8440226 100644 --- a/frontend/src/components/WellMeasurementModals.tsx +++ b/frontend/src/components/WellMeasurementModals.tsx @@ -48,15 +48,18 @@ export function NewMeasurementModal({ // Sends user entered information to the parent through callback function onMeasurementSubmitted() { - const d = new Date( - Date.parse(date?.format() ?? Date()), - ).toLocaleDateString(); - const t = new Date( - Date.parse(time?.format() ?? Date()), - ).toLocaleTimeString(); + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); handleSubmitNewMeasurement({ - timestamp: new Date(Date.parse(d + " " + t)), + timestamp: combinedDateTime.toISOString(), value: value as number, submitting_user_id: selectedUserID as number, well_id: -1, // Set by parent diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 00000000..386f9afa --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,21 @@ +export * from './BackgroundBox' +export * from './TristateToggle' +export * from './ChipSelect' +export * from './MergeWellModal' +export * from './RegionMeasurementModals' +export * from './UserSelection' +export * from './CustomCardHeader' +export * from './MeterRegisterSelect' +export * from './RHControlled' +export * from './DirectionCard' +export * from './MeterSelection' +export * from './StatCell' +export * from './WellSelection' +export * from './MeterTypeSelect' +export * from './TabPanel' +export * from './Layers' +export * from './WorkOrderSelect' +export * from './GridFooterWithButton' +export * from './NavLink' +export * from './Topbar' +export * from './WellMapLegend' diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 00000000..bf8eaaba --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,11 @@ +export const PM_COLORS: { [key: string]: string } = { + "2020/2021": "brown", + "2021/2022": "green", + "2022/2023": "purple", + "2023/2024": "turquoise", + "2024/2025": "red", + "2025/2026": "white", + "2026/2027": "yellow", + "2027/2028": "brown", + "2028/2029": "blue", +}; diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css deleted file mode 100644 index 0aa53806..00000000 --- a/frontend/src/css/sidebar.css +++ /dev/null @@ -1,44 +0,0 @@ -.sidebar { - width: 250px; - height: calc(100vh - 50px); - background-color: rgb(251, 251, 255); - position: sticky; - top: 50px; -} - -.sidebarWrapper { - padding: 20px; - color: #555; -} - -.sidebarMenu { - margin-bottom: 10px; -} - -.sidebarTitle { - font-size: 13px; - color: rgb(187, 186, 186); -} - -.sidebarList { - list-style: none; - padding: 5px; -} - -.sidebarListItem { - padding: 5px; - cursor: pointer; - display: flex; - align-items: center; - border-radius: 10px; -} - -.sidebarListItem.active, -.sidebarListItem:hover { - background-color: rgb(240, 240, 255); -} - -.sidebarIcon { - margin-right: 5px; - font-size: 20px !important; -} diff --git a/frontend/src/css/topbar.css b/frontend/src/css/topbar.css deleted file mode 100644 index 8f70a457..00000000 --- a/frontend/src/css/topbar.css +++ /dev/null @@ -1,57 +0,0 @@ -.topbar { - width: 100%; - height: 50px; - background-color: white; - position: sticky; - top: 0; - z-index: 999; -} - -.topbarWrapper { - height: 100%; - padding: 0px 20px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.logo { - font-weight: bold; - font-size: 30px; - color: darkblue; - cursor: pointer; -} - -.topRight { - display: flex; - align-items: center; -} - -.topbarIconContainer { - position: relative; - cursor: pointer; - margin-right: 10px; - color: #555; -} - -.topIconBadge { - width: 15px; - height: 15px; - position: absolute; - top: -5px; - right: 0px; - background-color: red; - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; -} - -.topAvatar { - width: 40px; - height: 40px; - border-radius: 50%; - cursor: pointer; -} diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 344b966f..00000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,28 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: #EEF2F6; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -.flex-container { - display: flex; -} - -.flex-child { - flex: 1; - border: 2px solid blue -} - -.flex-child:first-child { - margin-right: 20px; - width: 700px -} diff --git a/frontend/src/index.js b/frontend/src/index.js deleted file mode 100644 index 52558d58..00000000 --- a/frontend/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; -import reportWebVitals from './reportWebVitals'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -// reportWebVitals(); diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index 3ad17416..69224b1f 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -7,659 +7,660 @@ import exp from 'constants' export interface ActivityForm { - activity_details?: { - meter_id?: number - activity_type_id?: number - user_id?: number - date?: Dayjs - start_time?: Dayjs - end_time?: Dayjs - share_ose: boolean - work_order_id?: number - } - - current_installation?: { - contact_name?: string - contact_phone?: string - well_id?: number - notes?: string - water_users?: string - meter_owner?: string - } + activity_details?: { + meter_id?: number + activity_type_id?: number + user_id?: number + date?: Dayjs + start_time?: Dayjs + end_time?: Dayjs + share_ose: boolean + work_order_id?: number + } + + current_installation?: { + contact_name?: string + contact_phone?: string + well_id?: number + notes?: string + water_users?: string + meter_owner?: string + } - observations?: ObservationForm[] + observations?: ObservationForm[] - maintenance_repair?: { - service_type_ids: number[] - description: string - } + maintenance_repair?: { + service_type_ids: number[] + description: string + } - notes?: { - working_on_arrival_slug: string - selected_note_ids: number[] - } + notes?: { + working_on_arrival_slug: string + selected_note_ids: number[] + } - part_used_ids?: number[] + part_used_ids?: number[] } // This might could be the full things that are selected, but for now its only the things that are submitted/validated // These need to be the actual interfaces eventually, meter -> MeterListDTO export interface ActivityFormControl { - activity_details: { - selected_meter: Partial | null - activity_type: Partial | null - user: Partial | null - date: Dayjs - start_time: Dayjs - end_time: Dayjs - share_ose: boolean = false - work_order_id: number | null - }, - current_installation: { - meter: Partial | null - well: Partial | null - }, - observations: Array<{ - time: Dayjs - reading: '' | number - property_type: Partial | null - unit: Partial | null - }>, - maintenance_repair?: { - service_type_ids: number[] | null, - description: string - }, - notes: { - working_on_arrival_slug: string, - selected_note_ids: number[] | null - }, - part_used_ids?: [] + activity_details: { + selected_meter: Partial | null + activity_type: Partial | null + user: Partial | null + date: Dayjs + start_time: Dayjs + end_time: Dayjs + share_ose: boolean = false + work_order_id: number | null + }, + current_installation: { + meter: Partial | null + well: Partial | null + }, + observations: Array<{ + time: Dayjs + reading: '' | number + property_type: Partial | null + unit: Partial | null + }>, + maintenance_repair?: { + service_type_ids: number[] | null, + description: string + }, + notes: { + working_on_arrival_slug: string, + selected_note_ids: number[] | null + }, + part_used_ids?: [] } export interface MeterActivity { - id: int - timestamp_start: Date - timestamp_end: Date - notes?: string - submitting_user_id: int - meter_id: int - activity_type_id: int - location_id: int - - submitting_user?: User - meter?: Meter - activity_type?: ActivityTypeLU - location?: Location - parts_used?: [] + id: int + timestamp_start: Date + timestamp_end: Date + notes?: string + submitting_user_id: int + meter_id: int + activity_type_id: int + location_id: int + + submitting_user?: User + meter?: Meter + activity_type?: ActivityTypeLU + location?: Location + parts_used?: [] } //This is designed to match the HistoryDetails form rather than the patch meter API export interface PatchActivityForm { - activity_id: int - meter_id: int - activity_date: dayjs.Dayjs - activity_start_time: dayjs.Dayjs - activity_end_time: dayjs.Dayjs - activity_type: ActivityTypeLU - submitting_user: User - description: string + activity_id: int + meter_id: int + activity_date: dayjs.Dayjs + activity_start_time: dayjs.Dayjs + activity_end_time: dayjs.Dayjs + activity_type: ActivityTypeLU + submitting_user: User + description: string - well: Well | null - water_users?: string + well: Well | null + water_users?: string - notes?: NoteTypeLU[] - services?: ServiceTypeLU[] - parts_used?: Part[] + notes?: NoteTypeLU[] + services?: ServiceTypeLU[] + parts_used?: Part[] - ose_share: boolean + ose_share: boolean } //This interface is designed to match the backend API patch endpoint export interface PatchActivitySubmit { - activity_id: int - timestamp_start: string - timestamp_end: string - description: string - submitting_user_id: int - meter_id: int - activity_type_id: int - location_id: int | null - ose_share: boolean - water_users: string - - note_ids: int[] | null - service_ids: int[] | null - part_ids: int[] | null + activity_id: int + timestamp_start: string + timestamp_end: string + description: string + submitting_user_id: int + meter_id: int + activity_type_id: int + location_id: int | null + ose_share: boolean + water_users: string + + note_ids: int[] | null + service_ids: int[] | null + part_ids: int[] | null } //Designed for the HistoryDetails component, not the patch endpoint export interface PatchObservationForm { - observation_id: int - submitting_user: User - well: Well | null - observation_date: dayjs.Dayjs - observation_time: dayjs.Dayjs - property_type: ObservedPropertyTypeLU - unit: Unit - value: number - ose_share: boolean - notes?: string - meter_id: int + observation_id: int + submitting_user: User + well: Well | null + observation_date: dayjs.Dayjs + observation_time: dayjs.Dayjs + property_type: ObservedPropertyTypeLU + unit: Unit + value: number + ose_share: boolean + notes?: string + meter_id: int } export interface PatchObservationSubmit { - //Matches the backend API patch endpoint - observation_id: int - timestamp: string - value: number - notes: string | null - submitting_user_id: int - meter_id: int - observed_property_type_id: int - unit_id: int - location_id: int | null - ose_share: boolean + //Matches the backend API patch endpoint + observation_id: int + timestamp: string + value: number + notes: string | null + submitting_user_id: int + meter_id: int + observed_property_type_id: int + unit_id: int + location_id: int | null + ose_share: boolean } export interface ObservationForm { - time: Dayjs - reading: '' | number - property_type_id: '' | number - unit_id: '' | number + time: Dayjs + reading: '' | number + property_type_id: '' | number + unit_id: '' | number } export interface WellUseLU { - id: number - use_type?: string - code?: string - description?: string + id: number + use_type?: string + code?: string + description?: string } export interface PartTypeLU { - id: int - name: string - description?: string + id: int + name: string + description?: string } export interface Part { - id: number - part_number: string - part_type_id: number - vendor?: string - note?: string - description?: string - count?: number - in_use: boolean - commonly_used: boolean + id: number + part_number: string + part_type_id: number + vendor?: string + note?: string + description?: string + count?: number + in_use: boolean + commonly_used: boolean - part_type?: PartTypeLU - meter_types?: MeterTypeLU[] + part_type?: PartTypeLU + meter_types?: MeterTypeLU[] } export interface PartAssociation { - id: int - meter_type_id: int - part_id: int - commonly_used: boolean - part?: Part + id: int + meter_type_id: int + part_id: int + commonly_used: boolean + part?: Part } export interface ServiceTypeLU { - id: number - service_name: string - description?: string + id: number + service_name: string + description?: string } export interface NoteTypeLU { - id: number - note: string - details?: string - slug?: string - commonly_used: boolean + id: number + note: string + details?: string + slug?: string + commonly_used: boolean } export interface WellUseLU { - id: number - use_type: string - code: string - description: string + id: number + use_type: string + code: string + description: string } export interface WaterSource { - id: number - name: string - description: string + id: number + name: string + description: string } export interface WellStatus { - id: number - status: string - description: string + id: number + status: string + description: string } export interface SubmitWellCreate { - name: string - ra_number: string - owners: string - osetag: string - water_source: WaterSource | null - chloride_group_id: number | null - - use_type: { - id: number - } + name: string + ra_number: string + owners: string + osetag: string + water_source: WaterSource | null + chloride_group_id: number | null + + use_type: { + id: number + } - location: { - name: string, - trss: string, - longitude: float, - latitude: float - } + location: { + name: string, + trss: string, + longitude: float, + latitude: float + } } interface BaseWell { - id: number - name: string - ra_number: string - owners: string - osetag: string - casing: string - total_depth: number - outside_recorder: boolean - location_id: number - use_type_id: number - well_status_id: number - water_source_id: number - chloride_group_id: number | null + id: number + name: string + ra_number: string + owners: string + osetag: string + casing: string + total_depth: number + outside_recorder: boolean + location_id: number + use_type_id: number + well_status_id: number + water_source_id: number + chloride_group_id: number | null } export interface Well extends BaseWell { - use_type: WellUseLU | null - water_source: WaterSource | null - location: Location | null - well_status: WellStatus | null - - meters: [ - { - id: int - serial_number: string - water_users?: string - } - ] + use_type: WellUseLU | null + water_source: WaterSource | null + location: Location | null + well_status: WellStatus | null + + meters: [ + { + id: int + serial_number: string + water_users?: string + } + ] } export interface WellUpdate extends BaseWell { - use_type: WellUseLU - water_source: WaterSource - location: Location - well_status: WellStatus + use_type: WellUseLU + water_source: WaterSource + location: Location + well_status: WellStatus } export interface MeterDetailsQueryParams { - meter_id: number | undefined + meter_id: number | undefined } export interface MeterPartParams { - meter_id: number | undefined + meter_id: number | undefined } export interface WellDetailsQueryParams { - well_id: number | undefined + well_id: number | undefined } export interface WaterLevelQueryParams { - well_id: number | undefined + well_id: number | undefined } export interface WellMergeParams { - merge_well: string - target_well: string + merge_well: string + target_well: string } export interface ST2WaterLevelQueryParams { - $filter: string - $orderby: string - datastreamID: number | undefined + $filter: string + $orderby: string + datastreamID: number | undefined } export interface ActivityTypeLU { - id: number - name: string - description: string - permission: string + id: number + name: string + description: string + permission: string } export interface ObservedPropertyTypeLU { - id: number - name: string - description: string - context: string + id: number + name: string + description: string + context: string - units?: Unit[] + units?: Unit[] } export interface Unit { - id: number - name: string - name_short: string - description: string + id: number + name: string + name_short: string + description: string } export interface MeterHistoryDTO { - id: int - history_type: string - activity_type: string - date: Date - history_item: any - location: Location - well: Well | null + id: int + history_type: string + activity_type: string + date: Date + history_item: any + location: Location + well: Well | null } export interface MeterType { - id?: int - brand?: string - series?: string - model?: string - size?: float - description?: string + id?: int + brand?: string + series?: string + model?: string + size?: float + description?: string } export interface MeterRegister { - id: number - brand: string - meter_size: number - ratio: string | null - number_of_digits: number | null - decimal_digits: number | null - dial_units: Unit - totalizer_units: Unit - multiplier?: number | null + id: number + brand: string + meter_size: number + ratio: string | null + number_of_digits: number | null + decimal_digits: number | null + dial_units: Unit + totalizer_units: Unit + multiplier?: number | null } export interface MeterStatus { - id: number - status_name?: string - description?: string + id: number + status_name?: string + description?: string } export interface LandOwner { - id: number - contact_name?: string - land_owner_name?: string - organization?: string - phone?: string - email?: string - city?: string + id: number + contact_name?: string + land_owner_name?: string + organization?: string + phone?: string + email?: string + city?: string } export interface Location { - name: string - latitude: float - longitude: float - trss: string - land_owner_id: number + name: string + latitude: float + longitude: float + trss: string + land_owner_id: number - land_owner?: LandOwner + land_owner?: LandOwner } //Depricate this??? need to assess export interface MeterTypeLU { - id: number - brand: string - series: string - model: string - size: number - description: string - in_use: boolean + id: number + brand: string + series: string + model: string + size: number + description: string + in_use: boolean } export interface MeterDetails { - id?: number | null - serial_number?: string | null - contact_name?: string | null - contact_phone?: string | null - water_users?: string | null - meter_owner?: string | null - ra_number?: string | null - tag?: string | null - well_distance_ft?: float | null - notes?: string | null - meter_type_id?: int | null - well_id?: int | null - - meter_type: MeterType - status: MeterStatus - well: Well | null - meter_register: MeterRegister | null - // Also has parts_associated?: List[Part] + id?: number | null + serial_number?: string | null + contact_name?: string | null + contact_phone?: string | null + water_users?: string | null + meter_owner?: string | null + ra_number?: string | null + tag?: string | null + well_distance_ft?: float | null + notes?: string | null + meter_type_id?: int | null + well_id?: int | null + + meter_type: MeterType + status: MeterStatus + well: Well | null + meter_register: MeterRegister | null + // Also has parts_associated?: List[Part] } export interface MeterListQueryParams { - search_string?: string - filter_by_status?: MeterStatusNames[] - sort_by?: MeterSortByField - sort_direction?: SortDirection - limit?: number - offset?: number + search_string?: string + filter_by_status?: MeterStatusNames[] + sort_by?: MeterSortByField + sort_direction?: SortDirection + limit?: number + offset?: number } export interface MeterMapDTO { - id: number - serial_number: string - well: { - ra_number: string - name: string - } - location: { - longitude: number - latitude: number - } - last_pm: string + id: number + serial_number: string + well: { + ra_number: string + name: string + } + location: { + longitude: number + latitude: number + } + last_pm: string } export interface Organization { - organization_name: string + organization_name: string } export interface Meter { - id: number - serial_number: string - contact_name?: string - contact_phone?: string - notes?: string + id: number + serial_number: string + contact_name?: string + contact_phone?: string + notes?: string - meter_type_id: number - status_id?: number - well_id: number - location_id?: number + meter_type_id: number + status_id?: number + well_id: number + location_id?: number - meter_register?: MeterRegister - meter_type?: MeterType - status?: MeterStatus - well?: Well - location?: Location + meter_register?: MeterRegister + meter_type?: MeterType + status?: MeterStatus + well?: Well + location?: Location } export interface MeterListDTO { - id: number - serial_number: string - status?: {status_name?: string} - water_users: string - location: { - trss: string - longitude: number - latitude: number - } - well: { - ra_number: string - name: string - owners: string - } + id: number + serial_number: string + status?: { status_name?: string } + water_users: string + location: { + trss: string + longitude: number + latitude: number + } + well: { + ra_number: string + name: string + owners: string + } } interface WellListQueryParams { - search_string?: string - // sort_by?: WellSortByField - sort_direction?: SortDirection - limit?: number - offset?: number - exclude_inactive?: boolean + search_string?: string + // sort_by?: WellSortByField + sort_direction?: SortDirection + limit?: number + offset?: number + exclude_inactive?: boolean } export interface Page { - items: T[] - total: number - limit: number - offset: number + items: T[] + total: number + limit: number + offset: number } export interface MeterListQuery { - search_string: string - sort_by: MeterListSortBy - sort_direction: SortDirection - limit: number, - offset: number + search_string: string + sort_by: MeterListSortBy + sort_direction: SortDirection + limit: number, + offset: number } // Single manual measurement from a certain well export interface WellMeasurementDTO { - id: number - timestamp: Date - value: number - submitting_user: {full_name: string} + id: number + timestamp: Date + value: number + submitting_user: { full_name: string } + well: { id: number, ra_number: string } } export interface RegionMeasurementDTO { - id: number - timestamp: Date - value: number - submitting_user: { id: number, full_name: string } - well: { id: number, ra_number: string } + id: number + timestamp: Date + value: number + submitting_user: { id: number, full_name: string } + well: { id: number, ra_number: string } } // Single value from a NM ST2 endpoint, many other fields are returned, these are the only ones used at the moment export interface ST2Measurement { - result: number - resultTime: Date - phenomenonTime: Date + result: number + resultTime: Date + phenomenonTime: Date } // Whole response returned from a NM ST2 endpoint export interface ST2Response { - "@iot.nextLink": string - value: [] + "@iot.nextLink": string + value: [] } // The object that gets sent to the backend to add a new measurement export interface NewWellMeasurement { - well_id: number - timestamp: Date - value: number - submitting_user_id: number + well_id: number + timestamp: string + value: number + submitting_user_id: number } export interface PatchWellMeasurement { - levelmeasurement_id: number - submitting_user_id: number - timestamp: dayjs.Dayjs - value: number + levelmeasurement_id: number + submitting_user_id: number + timestamp: dayjs.Dayjs + value: number } export interface NewRegionMeasurement { - region_id: number - timestamp: Date - value: number - submitting_user_id: number - well_id: number + region_id: number + timestamp: string + value: number + submitting_user_id: number + well_id: number } export interface PatchRegionMeasurement { - levelmeasurement_id: number - submitting_user_id: number - well_id: number - timestamp: dayjs.Dayjs - value: number + levelmeasurement_id: number + submitting_user_id: number + well_id: number + timestamp: dayjs.Dayjs + value: number } export interface CreateUser { - username: string - full_name: string - email: scope_string - disabled: boolean - user_role: {id: number} - password: string + username: string + full_name: string + email: scope_string + disabled: boolean + user_role: { id: number } + password: string } export interface UpdatedUserPassword { - user_id: number - new_password: string + user_id: number + new_password: string } export interface User { - id: number - username?: string - full_name: string - email?: scope_string - disabled: boolean - user_role_id?: number - user_role?: UserRole + id: number + username?: string + full_name: string + email?: scope_string + disabled: boolean + user_role_id?: number + user_role?: UserRole - password?: string + password?: string } export interface NewUser { - id: number - username: string - full_name: string - email: scope_string - disabled: boolean - user_role_id: number - password: string + id: number + username: string + full_name: string + email: scope_string + disabled: boolean + user_role_id: number + password: string } export interface UserRole { - id: number - name: string - security_scopes: SecurityScope[] + id: number + name: string + security_scopes: SecurityScope[] } export interface SecurityScope { - id: number - scope_string: string - description: string + id: number + scope_string: string + description: string } export interface WorkOrder { - work_order_id: number - date_created: Date - creator?: String - meter_serial: String - title: String - description: String - status: String - notes?: String - assigned_user_id?: number - assigned_user?: String - associated_activities?: number[] + work_order_id: number + date_created: Date + creator?: String + meter_serial: String + title: String + description: String + status: String + notes?: String + assigned_user_id?: number + assigned_user?: String + associated_activities?: number[] } export interface NewWorkOrder { - //Just the bare minimum to create a new work order - //No work order ID since it is generated by the backend - date_created: Date //This should be on the frontend to ensure it doesn't reflect server time - meter_id: number - title: string + //Just the bare minimum to create a new work order + //No work order ID since it is generated by the backend + date_created: Date //This should be on the frontend to ensure it doesn't reflect server time + meter_id: number + title: string } export interface PatchWorkOrder { - // This is designed to match the backend API patch endpoint and is limited to the fields that can be updated - work_order_id: number - title?: string - description?: string - status?: string - notes?: string - assigned_user_id?: number + // This is designed to match the backend API patch endpoint and is limited to the fields that can be updated + work_order_id: number + title?: string + description?: string + status?: string + notes?: string + assigned_user_id?: number } export interface MonitoredWell { diff --git a/frontend/src/login.tsx b/frontend/src/login.tsx deleted file mode 100644 index a3394c19..00000000 --- a/frontend/src/login.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useSignIn } from "react-auth-kit"; -import { - Box, - TextField, - Button, - Card, - CardContent, - CardHeader, - Alert, - Divider, - Typography, - Stack, - Grid, -} from "@mui/material"; -import { enqueueSnackbar } from "notistack"; -import { SecurityScope } from "./interfaces"; -import { API_URL } from "./config"; - -const Login = () => { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - - const signIn = useSignIn(); - const navigate = useNavigate(); - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - - const body = new FormData(); - body.append("username", username); - body.append("password", password); - - fetch(`${API_URL}/token`, { method: "POST", body }) - .then(handleLogin) - .catch((_) => { - setError( - "Unable to connect to the server. Please check your internet connection and try again. If the issue persists, contact support.", - ); - }); - }; - - function handleLogin(res: Response) { - if (res.status === 200) { - res.json().then((data) => { - if ( - !data?.user?.user_role?.security_scopes - ?.map((scope: SecurityScope) => scope.scope_string) - .find((scope_string: string) => scope_string == "read") - ) { - enqueueSnackbar( - "Your role does not have access to the site UI. Please try accessing data via our API.", - { variant: "error" }, - ); - return; - } - if ( - signIn({ - token: data.access_token, - expiresIn: 300, - tokenType: "bearer", - authState: data.user, - }) - ) { - localStorage.setItem("_auth", data.access_token); - localStorage.setItem("loggedIn", "true"); - - navigate("/home"); - } else { - setError("Invalid username or password. Please try again."); - } - }); - } else { - setError("Login failed. Please check your credentials and try again."); - } - } - - return ( - <> - - - PVACD Meter Manager Home - - - - Login - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - - - - - - {error?.trim() && ( - - {error} - - )} - - - ); -}; - -export default Login; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202a..a1b5c629 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,9 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index d8acc1a6..b1af9629 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient, UseQueryOptions } from "react-query"; import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { @@ -237,15 +237,21 @@ export function useGetMeterLocations(searchstring: string | undefined) { const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery([route, searchstring], () => - GETFetch( + return useQuery({ + queryKey: [route, searchstring], + queryFn: () => GETFetch( route, { search_string: searchstring }, authHeader(), signOut, navigate, ), - ); + staleTime: 1000 * 60 * 60 * 24, // 24 hours + cacheTime: 1000 * 60 * 60 * 24, // keep in memory for 24 hours + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }); } export function useGetMeterTypeList() { @@ -417,25 +423,37 @@ export function useGetWells(params: WellListQueryParams | undefined) { ); } -// Start Get Well List for Map View -export function useGetWellLocations(searchstring: string | undefined) { +export function useGetWellLocations(searchstring: string | undefined, has_chloride_group: boolean | null = null) { const route = "well_locations"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); + const PAGE_SIZE = 500; - return useQuery, Error>([route, searchstring], () => - GETFetch( - route, - { search_string: searchstring }, - authHeader(), - signOut, - navigate, - ), - ); + return useInfiniteQuery({ + queryKey: [route, searchstring, has_chloride_group], + queryFn: async ({ pageParam = 0 }) => { + return GETFetch( + route, + { search_string: searchstring, offset: pageParam, limit: PAGE_SIZE, has_chloride_group }, + authHeader(), + signOut, + navigate + ); + }, + getNextPageParam: (lastPage, allPages) => { + // If we got less than PAGE_SIZE, we’re done + if (!lastPage || lastPage.length < PAGE_SIZE) return undefined; + return allPages.length * PAGE_SIZE; // next offset + }, + staleTime: 1000 * 60 * 60 * 24, + cacheTime: 1000 * 60 * 60 * 24, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }); } -// End export function useGetWell(params: WellDetailsQueryParams | undefined) { const route = "well"; @@ -453,16 +471,6 @@ export function useGetWell(params: WellDetailsQueryParams | undefined) { ); } -// export function useGetWellLocations(searchstring: string | undefined) { -// const route = 'well_locations' -// const authHeader = useAuthHeader() -// const navigate = useNavigate() -// const signOut = useSignOut() - -// return useQuery, Error>([route, searchstring], () => -// GETFetch(route, {search_string: searchstring}, authHeader(), signOut, navigate), -// ) -// } export function useGetMeter(params: MeterDetailsQueryParams | undefined) { const route = "meter"; const authHeader = useAuthHeader(); @@ -550,23 +558,26 @@ export function useGetST2WaterLevels(datastreamID: number | undefined) { ); } -export function useGetWorkOrders(status_filter: WorkOrderStatus[]) { +export function useGetWorkOrders( + status_filter: WorkOrderStatus[], + options?: UseQueryOptions +) { const route = "work_orders"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - //Convert status filter array to - - return useQuery([route, status_filter], () => - GETFetch( + return useQuery({ + queryKey: [route, { status_filter: status_filter.sort() }], + queryFn: () => GETFetch( route, { filter_by_status: status_filter }, authHeader(), signOut, navigate, ), - ); + ...options + }); } export function useCreateUser(onSuccess: Function) { diff --git a/frontend/src/sidenav.css b/frontend/src/sidenav.css deleted file mode 100644 index 8ec24dfd..00000000 --- a/frontend/src/sidenav.css +++ /dev/null @@ -1,16 +0,0 @@ -.navbar-link { - &:hover { - background-color: rgb(240, 240, 255); - } - padding: 5px; - border-radius: 10px; - margin-left: 5px; - text-decoration: none; - color: #555555; - display: flex; - alignItems: center -} - -.navbar-link-active { - background-color: rgb(240, 240, 255); -} diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index ba33c1db..dd73a22c 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,15 +1,14 @@ -import React, { useEffect, useState } from "react"; -import TableViewIcon from "@mui/icons-material/TableView"; -import { Link, useLocation } from "react-router-dom"; +import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { Grid, SvgIconProps } from "@mui/material"; +import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/material"; +import { useNavigate } from "react-router-dom"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; -import { WorkOrder } from "./interfaces"; - -import "./sidenav.css"; +import { SecurityScope, WorkOrder } from "./interfaces"; import { + Assessment, Build, + ChevronLeft, Construction, FormatListBulletedOutlined, Home, @@ -19,103 +18,157 @@ import { Science, ScreenshotMonitor, } from "@mui/icons-material"; +import { NavLink } from "./components/NavLink"; -export default function Sidenav() { - let location = useLocation(); +export default function Sidenav({ + open, + drawerWidth, + onClose, +}: { + open: boolean; + drawerWidth: number; + onClose: () => void; +}) { + const navigate = useNavigate(); const authUser = useAuthUser(); - const hasAdminScope = authUser() - ?.user_role.security_scopes.map((scope: any) => scope.scope_string) - .includes("admin"); + + // Normalize scopes into a Set for O(1) lookups + const scopes: Set = new Set( + authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string + ) ?? [] + ); + + 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 workOrderList = useGetWorkOrders([WorkOrderStatus.Open], { + refetchInterval: 45_000, + refetchIntervalInBackground: true, + enabled: hasReadScope && !!authUser() + }); useEffect(() => { if (workOrderList.data && userID) { - let userWorkOrders = workOrderList.data.filter( - (workOrder: WorkOrder) => workOrder.assigned_user_id == userID, + const userWorkOrders = workOrderList.data.filter( + (workOrder: WorkOrder) => workOrder.assigned_user_id === userID + ); + setWorkOrderLabel( + userWorkOrders.length > 0 + ? `Work Orders (${userWorkOrders.length})` + : "Work Orders" ); - let numberOfWorkOrders = userWorkOrders.length; - if (numberOfWorkOrders > 0) { - setWorkOrderLabel(`Work Orders (${numberOfWorkOrders})`); - } else { - setWorkOrderLabel("Work Orders"); - } } }, [workOrderList.data, userID]); - //Refresh work order list once a minute - useEffect(() => { - const interval = setInterval(() => { - workOrderList.refetch(); - }, 60000); - return () => clearInterval(interval); - }, []); - - const NavLink = ({ - route, - label, - Icon, - }: { - route: string; - label: string; - Icon?: React.ComponentType; - }) => { - return ( - - - {Icon ? ( - - ) : ( - - )} -
{label}
- -
- ); - }; - return ( - - -
Pages
-
+ {/* Header */} + + navigate("/")} + > + Meter Manager + + + + + + + + + {/* Nav Items */} + + +
Pages
+
- - - - - - + - {hasAdminScope && ( - <> - -
Admin Management
-
- - - - - )} -
+ {hasReadScope && ( + <> + + + + + + + + )} + + {hasAdminScope && ( + <> + +
Admin Management
+
+ + + + + )} +
+ ); } diff --git a/frontend/src/utils/GetMeterMarkerColor.ts b/frontend/src/utils/GetMeterMarkerColor.ts new file mode 100644 index 00000000..540d60d1 --- /dev/null +++ b/frontend/src/utils/GetMeterMarkerColor.ts @@ -0,0 +1,14 @@ +import { PM_COLORS } from "../constants"; + +export const getMeterMarkerColor = (last_pm: string) => { + const last_pm_date = new Date(last_pm); + if (last_pm_date.getMonth() >= 7) { + return PM_COLORS[ + last_pm_date.getFullYear() + "/" + (last_pm_date.getFullYear() + 1) + ]; + } else { + return PM_COLORS[ + last_pm_date.getFullYear() - 1 + "/" + last_pm_date.getFullYear() + ]; + } +} diff --git a/frontend/src/utils/MonitoredWellsUtils.ts b/frontend/src/utils/MonitoredWellsUtils.ts new file mode 100644 index 00000000..b14be29b --- /dev/null +++ b/frontend/src/utils/MonitoredWellsUtils.ts @@ -0,0 +1,21 @@ +import { MonitoredWell } from "../interfaces"; + +export const separateAndSortMonitoredWells = ( + wells: MonitoredWell[] = [], +): [MonitoredWell[], MonitoredWell[]] => { + const sortWells = (w: MonitoredWell[]) => + w.slice().sort((a, b) => { + if (!a.name) return 1; // Move undefined/null names to the bottom + if (!b.name) return -1; + return a.name.localeCompare(b.name); + }); + + const outsideRecorderWells = sortWells( + wells.filter((well) => well.outside_recorder === true), + ); + const regularWells = sortWells( + wells.filter((well) => well.outside_recorder !== true), + ); + + return [outsideRecorderWells, regularWells]; +}; diff --git a/frontend/src/utils/NumberDataFormatter.ts b/frontend/src/utils/NumberDataFormatter.ts new file mode 100644 index 00000000..8ca84380 --- /dev/null +++ b/frontend/src/utils/NumberDataFormatter.ts @@ -0,0 +1,5 @@ +// Small formatter so numbers look nice (e.g., 1,234.57) and undefined shows "β€”" +export const formatNumberData = (n?: number) => + typeof n === "number" + ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(n) + : "β€”"; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 00000000..2158b20b --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from "./DateUtils" +export * from "./HttpUtils" +export * from "./GetMeterMarkerColor" +export * from "./MonitoredWellsUtils" +export * from "./NumberDataFormatter" diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index 976b42c6..c538847f 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,35 +1,23 @@ -import { useState } from "react"; -import { Box, Grid, CardContent, Card } from "@mui/material"; -import TabPanel from "../../components/TabPanel"; +import { CardContent, Card } from "@mui/material"; import MeterActivityEntry from "./MeterActivityEntry/MeterActivityEntry"; +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 default function ActivitiesView() { - const [currentTabIndex, _] = useState(0); - +export const ActivitiesView = () => { return ( - -

- Submit an Activity -

- - - - - - - - -
Not Yet Implemented
-
-
-
-
-
-
+ + + + + + + + ); -} +}; diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx index e33d19f0..80487f33 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useState } from "react"; -import { Alert, Button, Grid } from "@mui/material"; +import { Alert, Box, Button, Stack, Typography } from "@mui/material"; import { useSnackbar } from "notistack"; import { useForm, SubmitHandler } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -79,17 +79,17 @@ export default function MeterActivityEntry() { 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), + ActivityType.Install) || + (meterDetails.data?.status.status_name != "Installed" && + watch("activity_details.activity_type")?.name == + ActivityType.Uninstall), ); }, [meterDetails.data, watch("activity_details.activity_type")?.name]); useEffect(() => { setIsMeterAndActivitySelected( watch("activity_details.selected_meter") != null && - watch("activity_details.activity_type") != null, + watch("activity_details.activity_type") != null, ); }, [ watch("activity_details.selected_meter"), @@ -115,48 +115,20 @@ export default function MeterActivityEntry() { const hasErrors = (errors: any) => Object.keys(errors).length > 0; return ( - <> - + + + {!hasMeterActivityConflict && isMeterAndActivitySelected ? ( - <> - - - - - - + + + + + + + + {hasErrors(errors) ? ( - + Please correct any errors before submission. ) : ( @@ -164,27 +136,27 @@ export default function MeterActivityEntry() { variant="contained" type="submit" onClick={handleSubmit(onSubmit)} + sx={{ width: { xs: "100%", sm: "auto" } }} > Submit )} - - + + ) : ( - - - {hasMeterActivityConflict ? ( -

- You cannot install a meter that is already installed, or - uninstall a meter that is not currently installed. Please choose - a different activity or meter. -

- ) : ( -

Please select a meter and activity to begin.

- )} -
-
+ + + {hasMeterActivityConflict + ? "You cannot install a meter that is already installed, or uninstall a meter that is not currently installed. Please choose a different activity or meter." + : "Please select a meter and activity to begin."} + + )} - + ); } diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx index 89a6103a..a34539a6 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx @@ -10,13 +10,6 @@ import { ControlledWorkOrderSelect } from "../../../components/RHControlled/Cont export function MeterActivitySelection({ control, errors, setValue }: any) { return ( -

- Activity Details -

- { - if (isLoading) { - return ( - - Loading... - - ); - } - const data: Partial[] = useMemo(() => { const wellData: Record = {}; @@ -50,28 +34,46 @@ export const ChloridesPlot = ({ }, [manual_dates, manual_vals]); return ( - {/* Added margin of 5 pixels */} - + + {isLoading ? + + + + Loading plot data... + + + : + + } ); }; diff --git a/frontend/src/views/Chlorides/ChloridesTable.tsx b/frontend/src/views/Chlorides/ChloridesTable.tsx index cdc2c070..52665bda 100644 --- a/frontend/src/views/Chlorides/ChloridesTable.tsx +++ b/frontend/src/views/Chlorides/ChloridesTable.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { Box, Button } from "@mui/material"; import { DataGrid, GridPagination, GridColDef } from "@mui/x-data-grid"; +import AddIcon from "@mui/icons-material/Add"; import { RegionMeasurementDTO } from "../../interfaces"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -10,7 +11,7 @@ dayjs.extend(utc); dayjs.extend(timezone); declare module "@mui/x-data-grid" { - interface FooterPropsOverrides extends Partial {} + interface FooterPropsOverrides extends Partial { } } interface FooterExtraProps { @@ -102,8 +103,14 @@ const Footer = ({ {isRegionSelected ? ( - ) : null} diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 22bda825..2df99a76 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -1,14 +1,15 @@ import { useId, useState } from "react"; import { - Box, FormControl, Select, MenuItem, InputLabel, Card, CardContent, - Typography, - CardHeader, + Alert, + Button, + AlertTitle, + Grid, } from "@mui/material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -27,8 +28,10 @@ import { import dayjs, { Dayjs } from "dayjs"; import { useFetchWithAuth } from "../../hooks"; import { Science } from "@mui/icons-material"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function ChloridesView() { +export const ChloridesView = () => { const fetchWithAuth = useFetchWithAuth(); const selectedRegionId = useId(); const [regionId, setregionId] = useState(); @@ -53,6 +56,7 @@ export default function ChloridesView() { data: regions, isLoading: isLoadingRegions, error: errorRegions, + refetch: refetchRegions, } = useQuery<{ id: number; names: string[] }[], Error>({ queryKey: ["regions"], queryFn: () => @@ -178,32 +182,38 @@ export default function ChloridesView() { }; return ( - - - - Chlorides - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + {error && ( - - An error had occurred while attempting to loading data - + refetchRegions()} + > + Retry + + } + > + Error Loading Data + We couldn’t load chloride data. Please check your connection or try + again. + )} - Region - - - - setIsNewModalOpen(true)} - onMeasurementSelect={handleMeasurementSelect} - /> - - + m.timestamp) ?? []} @@ -252,16 +248,22 @@ export default function ChloridesView() { })) ?? [] } /> - - - + + + setIsNewModalOpen(true)} + onMeasurementSelect={handleMeasurementSelect} + /> + + setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> - - + ); -} +}; diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx new file mode 100644 index 00000000..29016cc7 --- /dev/null +++ b/frontend/src/views/Home.tsx @@ -0,0 +1,93 @@ +import { Grid, Card, CardContent, CardMedia, List, ListItem, ListItemText, Stack, Typography } from "@mui/material"; +import pvacd_logo from "../img/pvacd_logo.png"; +import meter_field from "../img/meter_field.jpg"; +import meter_storage from "../img/meter_storage.jpg"; +import HomeIcon from "@mui/icons-material/Home"; +import { BackgroundBox } from "../components/BackgroundBox"; +import { CustomCardHeader } from "../components/CustomCardHeader"; + +export const Home = () => { + const versionHistory = [ + "V0.2.0 - Parts-used report functional with PDF download", + "V0.1.52 - Deploy chlorides for admin testing", + "V0.1.51 - Improved monitoring well page", + "V0.1.50 - Fixed wells map bug and update register if part used", + "V0.1.49 - Added outside recorder wells to monitoring page", + "V0.1.48 - Changed well owner to be meter water users", + "V0.1.47 - Add TRSS grids to meter map and fixed meter register save bug", + "V0.1.46 - Change how data is displayed in Wells table", + "V0.1.45 - Color code meter markers on map by last PM", + "V0.1.44 - Fix bug in continuous monitoring well data and added data to OSE endpoint", + 'V0.1.43 - Fix navigation from work orders to activity, add OSE endpoint for "data issues"', + "V0.1.42 - Fix pagination, add 'uninstall and hold'", + "V0.1.41 - Add UI for water source on wells and some other minor changes", + ]; + + return ( + + + + + + + + + PVACD Meter Manager Info + Version History + + {versionHistory.map((version) => ( + + + + ))} + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx new file mode 100644 index 00000000..75a15d17 --- /dev/null +++ b/frontend/src/views/Login.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useSignIn } from "react-auth-kit"; +import { + Box, + TextField, + Button, + Card, + CardContent, + Alert, + Stack, + Grid, +} from "@mui/material"; +import LoginIcon from '@mui/icons-material/Login'; +import { enqueueSnackbar } from "notistack"; +import { SecurityScope } from "../interfaces"; +import { API_URL } from "../config"; +import { CustomCardHeader } from "../components"; + +export const Login = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const signIn = useSignIn(); + const navigate = useNavigate(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const body = new FormData(); + body.append("username", username); + body.append("password", password); + + fetch(`${API_URL}/token`, { method: "POST", body }) + .then(handleLogin) + .catch((_) => { + setError( + "Unable to connect to the server. Please check your internet connection and try again. If the issue persists, contact support." + ); + }); + }; + + function handleLogin(res: Response) { + if (res.status === 200) { + res.json().then((data) => { + if ( + !data?.user?.user_role?.security_scopes + ?.map((scope: SecurityScope) => scope.scope_string) + .find((scope_string: string) => scope_string == "read") + ) { + enqueueSnackbar( + "Your role does not have access to the site UI. Please try accessing data via our API.", + { variant: "error" } + ); + return; + } + if ( + signIn({ + token: data.access_token, + expiresIn: 300, + tokenType: "bearer", + authState: data.user, + }) + ) { + localStorage.setItem("_auth", data.access_token); + localStorage.setItem("loggedIn", "true"); + const savedRedirect = localStorage.getItem("redirectPage") ?? "/"; + navigate(savedRedirect); + } else { + setError("Invalid username or password. Please try again."); + } + }); + } else { + setError("Login failed. Please check your credentials and try again."); + } + } + + return ( + + + + + + + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + + + + + + {error?.trim() && ( + + {error} + + )} + + ); +}; + +export default Login; + diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 109e3d56..53f150ac 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -7,7 +7,7 @@ import GradingIcon from "@mui/icons-material/Grading"; import AddIcon from "@mui/icons-material/Add"; import SaveIcon from "@mui/icons-material/Save"; import SaveAsIcon from "@mui/icons-material/SaveAs"; -import { Button, Grid, Card, CardContent, CardHeader } from "@mui/material"; +import { Button, Grid, Card, CardContent } from "@mui/material"; import { Table, TableBody, @@ -27,10 +27,10 @@ import { yupResolver } from "@hookform/resolvers/yup"; import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; import ControlledMeterTypeSelect from "../../components/RHControlled/ControlledMeterTypeSelect"; import ControlledWellSelection from "../../components/RHControlled/ControlledWellSelection"; -import ControlledMeterStatusTypeSelect from "../../components/RHControlled/ControlledMeterStatusTypeSelect"; // This import is missing from the snippet - +import ControlledMeterStatusTypeSelect from "../../components/RHControlled/ControlledMeterStatusTypeSelect"; import { formatLatLong } from "../../conversions"; import ControlledMeterRegisterSelect from "../../components/RHControlled/ControlledMeterRegisterSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), @@ -38,13 +38,13 @@ const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ meter_register: Yup.object().required("Please select a meter register."), }); -export default function MeterDetailsFields({ +export const MeterDetailsFields = ({ selectedMeterID, meterAddMode, }: { selectedMeterID?: number; meterAddMode: boolean; -}) { +}) => { const meterDetails = useGetMeter({ meter_id: selectedMeterID }); const navigate = useNavigate(); const authUser = useAuthUser(); @@ -77,7 +77,6 @@ export default function MeterDetailsFields({ const createMeter = useCreateMeter(onSuccessfulCreate); const onSaveChanges: SubmitHandler = (data) => { - //console.log(data) updateMeter.mutate(data); }; const onAddMeter: SubmitHandler = (data) => { @@ -110,19 +109,7 @@ export default function MeterDetailsFields({ } }, [meterAddMode]); - // Clear meter register when meter type changes - // const prevMeterTypeRef = React.useRef(); - // useEffect(() => { - // console.log('changing meter type') - // const currentMeterType = watch("meter_type"); - // //Only clear if changing from one meter type to another - // if (prevMeterTypeRef.current !== undefined && prevMeterTypeRef.current !== currentMeterType) { - // setValue('meter_register', undefined); - // } - // prevMeterTypeRef.current = currentMeterType; - // }, [watch("meter_type")]); - - function navigateToNewActivity() { + const navigateToNewActivity = () => { navigate({ pathname: "/activities", search: createSearchParams({ @@ -130,25 +117,13 @@ export default function MeterDetailsFields({ serial_number: meterDetails.data?.serial_number ?? "", }).toString(), }); - } + }; return ( - - Add New Meter - - - ) : ( -
- Selected Meter Details - -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -343,4 +318,4 @@ export default function MeterDetailsFields({
); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index a2c5c15f..a24cc114 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -1,10 +1,9 @@ import { useState, useEffect } from "react"; import { Box, Grid } from "@mui/material"; - -import MeterHistoryTable from "./MeterHistoryTable"; -import SelectedActivityDetails from "./SelectedActivityDetails"; -import SelectedObservationDetails from "./SelectedObservationDetails"; -import SelectedBlankCard from "./SelectedBlankCard"; +import { MeterHistoryTable } from "./MeterHistoryTable"; +import { SelectedActivityDetails } from "./SelectedActivityDetails"; +import { SelectedObservationDetails } from "./SelectedObservationDetails"; +import { SelectedBlankCard } from "./SelectedBlankCard"; import { useLocation, useSearchParams } from "react-router-dom"; import { useGetMeterHistory } from "../../../service/ApiServiceNew"; import { @@ -19,11 +18,11 @@ import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -export default function MeterHistory({ +export const MeterHistory = ({ selectedMeterID, }: { - selectedMeterID: number | undefined; -}) { + selectedMeterID?: number; +}) => { const location = useLocation(); const [selectedHistoryItem, setSelectedHistoryItem] = useState(); const meterHistory = useGetMeterHistory({ meter_id: selectedMeterID }); @@ -40,7 +39,6 @@ export default function MeterHistory({ item.history_item.id == activity_id && item.history_type == MeterHistoryType.Activity, ); - //console.log('history item: ', load_history_item) if (load_history_item) { setSelectedHistoryItem(load_history_item); @@ -53,11 +51,10 @@ export default function MeterHistory({ // Remove the hash from the URL so that the user can switch meters without scrolling location.hash = ""; } else { - console.log("element not found"); + console.error("element not found"); } } // Clear the activity_id from the URL so it doesn't interfere later - console.log("clearing query string"); setSearchParams(); } }, [meterHistory.data]); // Run the effect only when meter history changes otherwise there is a race condition @@ -124,11 +121,10 @@ export default function MeterHistory({ return observation_details; } - //Function to determine what type of details card to output - function getDetailsCard(historyItem: MeterHistoryDTO | undefined) { - if (historyItem == undefined) { - return ; - } else if (historyItem.history_type == MeterHistoryType.Activity) { + const getDetailsCard = (historyItem?: MeterHistoryDTO): JSX.Element => { + if (!historyItem) return ; + + if (historyItem.history_type === MeterHistoryType.Activity) { return ( ); - } else { - return ( - - ); } - } + + return ( + + ); + }; return ( - + - + {getDetailsCard(selectedHistoryItem)} ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index a7427e74..6323bb46 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -1,90 +1,83 @@ -import { Card, CardContent, CardHeader } from "@mui/material"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import HistoryIcon from "@mui/icons-material/History"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc); -dayjs.extend(timezone); - -import { MeterHistoryType } from "../../../enums"; -import { MeterHistoryDTO } from "../../../interfaces"; - -export default function MeterHistoryTable({ - onHistoryItemSelection, - selectedMeterHistory, -}: { - onHistoryItemSelection: Function; - selectedMeterHistory: MeterHistoryDTO[] | undefined; -}) { - const handleRowSelect = (rowDetails: any) => { - onHistoryItemSelection(rowDetails.row); - }; - - const columns: GridColDef[] = [ - { - field: "date", - headerName: "Date", - valueGetter: (value) => { - return dayjs.utc(value).tz("America/Denver"); - }, - valueFormatter: (value) => { - return dayjs - .utc(value) - .tz("America/Denver") - .format("MM/DD/YYYY hh:mm A"); - }, - width: 200, - }, - { - field: "history_type", - headerName: "Activity Type", - valueGetter: (value, row) => { - if (row.history_type == MeterHistoryType.Activity) { - return row.history_item.activity_type.name; - } else return value; - }, - width: 200, - }, - { - field: "well", - headerName: "Well", - valueGetter: (value, row) => { - if (value === null) { - return ""; - } else return row.well.ra_number; - }, - width: 100, - }, - { - field: "history_item", - headerName: "Water Users", - valueGetter: (_, row) => { - return row.history_item.water_users; - }, - width: 200, - }, - ]; - - return ( - - - Meter History - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - ); -} +import { Card, CardContent } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import HistoryIcon from "@mui/icons-material/History"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +dayjs.extend(utc); +dayjs.extend(timezone); + +import { MeterHistoryType } from "../../../enums"; +import { MeterHistoryDTO } from "../../../interfaces"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; + +export const MeterHistoryTable = ({ + onHistoryItemSelection, + selectedMeterHistory, +}: { + onHistoryItemSelection: Function; + selectedMeterHistory: MeterHistoryDTO[] | undefined; +}) => { + const handleRowSelect = (rowDetails: any) => { + onHistoryItemSelection(rowDetails.row); + }; + + const columns: GridColDef[] = [ + { + field: "date", + headerName: "Date", + valueGetter: (value) => { + return dayjs.utc(value).tz("America/Denver"); + }, + valueFormatter: (value) => { + return dayjs + .utc(value) + .tz("America/Denver") + .format("MM/DD/YYYY hh:mm A"); + }, + width: 200, + }, + { + field: "history_type", + headerName: "Activity Type", + valueGetter: (value, row) => { + if (row.history_type == MeterHistoryType.Activity) { + return row.history_item.activity_type.name; + } else return value; + }, + width: 200, + }, + { + field: "well", + headerName: "Well", + valueGetter: (value, row) => { + if (value === null) { + return ""; + } else return row.well.ra_number; + }, + width: 100, + }, + { + field: "history_item", + headerName: "Water Users", + valueGetter: (_, row) => { + return row.history_item.water_users; + }, + width: 200, + }, + ]; + + return ( + + + + + + + ); +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx index a1a13337..1471eb3e 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx @@ -1,14 +1,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; -import { - Grid, - Card, - CardContent, - CardHeader, - Stack, - Button, -} from "@mui/material"; +import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { @@ -34,8 +27,9 @@ import NotesChipSelect from "../../../components/RHControlled/NotesChipSelect"; import ServicesChipSelect from "../../../components/RHControlled/ServicesChipSelect"; import PartsChipSelect from "../../../components/RHControlled/PartsChipSelect"; import ControlledCheckbox from "../../../components/RHControlled/ControlledCheckbox"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -export default function SelectedActivityDetails({ +export const SelectedActivityDetails = ({ selectedActivity, onDeletion, afterSave, @@ -43,7 +37,7 @@ export default function SelectedActivityDetails({ selectedActivity: PatchActivityForm; onDeletion: () => void; //Function to call when the activity is deleted, use to update the history table afterSave: () => void; -}) { +}) => { const { handleSubmit, control, @@ -124,14 +118,9 @@ export default function SelectedActivityDetails({ return ( - - Activity ID: {selectedActivity.activity_id} - - - } - sx={{ mb: 0, pb: 0 }} + @@ -259,4 +248,4 @@ export default function SelectedActivityDetails({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx index ba666944..c7b3f867 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx @@ -1,19 +1,12 @@ -import { Grid, Card, CardContent, CardHeader } from "@mui/material"; +import { Grid, Card, CardContent } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -//A blank card to display when no history item is selected -export default function SelectedBlankCard() { +// A blank card to display when no history item is selected +export const SelectedBlankCard = () => { return ( - - Selected Details - - - } - sx={{ mb: 0, pb: 0 }} - /> + Select a history item to view details @@ -21,4 +14,4 @@ export default function SelectedBlankCard() { ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx index a0a8faf4..d81e1433 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx @@ -2,14 +2,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { enqueueSnackbar } from "notistack"; -import { - Grid, - Card, - CardContent, - CardHeader, - Stack, - Button, -} from "@mui/material"; +import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { @@ -36,8 +29,9 @@ import { useUpdateObservation, useDeleteObservation, } from "../../../service/ApiServiceNew"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -export default function SelectedObservationDetails({ +export const SelectedObservationDetails = ({ selectedObservation, onDeletion, afterSave, @@ -45,7 +39,7 @@ export default function SelectedObservationDetails({ selectedObservation: PatchObservationForm; onDeletion: () => void; afterSave: () => void; -}) { +}) => { const { handleSubmit, control, reset, watch } = useForm( { defaultValues: selectedObservation }, ); @@ -120,14 +114,9 @@ export default function SelectedObservationDetails({ return ( - - Observation ID: {selectedObservation.observation_id} - - - } - sx={{ mb: 0, pb: 0 }} + @@ -244,4 +233,4 @@ export default function SelectedObservationDetails({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index d5ef2586..a60fd17a 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -1,9 +1,7 @@ import { useState } from "react"; - -import MeterSelectionTable from "./MeterSelectionTable"; +import { MeterSelectionTable } from "./MeterSelectionTable"; import MeterSelectionMap from "./MeterSelectionMap"; import TabPanel from "../../../components/TabPanel"; - import { Tabs, Tab, @@ -11,20 +9,22 @@ import { Grid, Card, CardContent, - CardHeader, ToggleButtonGroup, ToggleButton, + InputAdornment, } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { MeterStatusNames } from "../../../enums"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; +import { Search } from "@mui/icons-material"; -export default function MeterSelection({ +export const MeterSelection = ({ onMeterSelection, setMeterAddMode, }: { onMeterSelection: Function; setMeterAddMode: Function; -}) { +}) => { const [currentTabIndex, setCurrentTabIndex] = useState(0); const [meterSearchQuery, setMeterSearchQuery] = useState(""); const [meterFilterButtons, setMeterFilterButtons] = useState([ @@ -70,33 +70,43 @@ export default function MeterSelection({ return ( - - All Meters - - - } - sx={{ mb: 0, pb: 0 }} + - - + + - + { setMeterSearchQuery(e.target.value); }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> @@ -135,14 +145,16 @@ export default function MeterSelection({ - - + + + + ); -} +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 694ba666..f0cba18b 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -1,22 +1,19 @@ -import { useState, useEffect } from "react"; import { useDebounce } from "use-debounce"; - import { - CircleMarker, MapContainer, - TileLayer, Tooltip, GeoJSON, LayersControl, + Marker, Pane, } from "react-leaflet"; import { MeterMapDTO } from "../../../interfaces"; import L from "leaflet"; -import { useLeafletContext } from '@react-leaflet/core'; -import { FeatureCollection } from 'geojson'; +import { FeatureCollection } from "geojson"; import "leaflet/dist/leaflet.css"; +import "@changey/react-leaflet-markercluster/dist/styles.min.css"; import "../../../css/map.css"; import { useGetMeterLocations } from "../../../service/ApiServiceNew"; import * as tr_data from "../../../data/RoswellTR_v2.json"; @@ -24,178 +21,174 @@ import * as ss_data from "../../../data/RoswellSS.json"; import icon from "leaflet/dist/images/marker-icon.png"; import iconShadow from "leaflet/dist/images/marker-shadow.png"; -const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); - -L.Marker.prototype.options.icon = DefaultIcon; - -interface MeterSelectionMapProps { - meterSearch: string; - onMeterSelection: Function; -} - -// Define marker colors which are based on the year of the last PM (July - June) -const pm_colors: { [key: string]: string } = { - "2020/2021": "brown", - "2021/2022": "green", - "2022/2023": "purple", - "2023/2024": "turquoise", - "2024/2025": "red", - "2025/2026": "white", - "2026/2027": "yellow", - "2027/2028": "brown", - "2028/2029": "blue", -}; - -// Map legend for PM colors -function ColorLegend() { - const context = useLeafletContext(); - - useEffect(() => { - const legend = new L.Control({ position: "bottomleft" }); - legend.onAdd = function () { - const div = L.DomUtil.create("div", "info legend"); - const seasons = Object.keys(pm_colors); - - // Add title to legend - div.innerHTML = "

PM Season

"; - - // loop through PM seasons and generate a label with a colored square for each interval - for (var i = 0; i < seasons.length; i++) { - div.innerHTML += - ' ' + - seasons[i] + - "
"; - } - - return div; - }; - - const container = context.map; - container.addControl(legend); +import { Box, Typography } from "@mui/material"; - return () => { - container.removeControl(legend); - }; - }); +// @ts-ignore +import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; +import { OpenStreetMapLayer, SatelliteLayer } from "../../../components"; +import { getMeterMarkerColor } from "../../../utils"; +import { MeterMapColorLegend } from "../../../components/MeterMapColorLegend"; - return null; -} - -// Function for getting color from last PM which is based on year and month -function getMeterColor(last_pm: string) { - // The string has the format "YYYY-MM-DDTHH:MM:SSZ" Use month and year to determine color - //Convert string to a date object - const last_pm_date = new Date(last_pm); - - // Test if the date is in or after July - if (last_pm_date.getMonth() >= 7) { - return pm_colors[ - last_pm_date.getFullYear() + "/" + (last_pm_date.getFullYear() + 1) - ]; - } else { - return pm_colors[ - last_pm_date.getFullYear() - 1 + "/" + last_pm_date.getFullYear() - ]; - } -} +const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); +L.Marker.prototype.options.icon = DefaultIcon; -//Specify the type of the trss_data +// Static geojson data const trData: FeatureCollection = tr_data as FeatureCollection; const ssData: FeatureCollection = ss_data as FeatureCollection; export default function MeterSelectionMap({ onMeterSelection, meterSearch, -}: MeterSelectionMapProps) { +}: { + meterSearch: string; + onMeterSelection: Function; +}) { const [meterSearchDebounced] = useDebounce(meterSearch, 250); - const [meterMarkersMap, setMeterMarkersMap] = useState([]); - - const mapStyle = { - height: "100%", - width: "100%", - }; - const meterMarkers = useGetMeterLocations(meterSearchDebounced); - useEffect(() => { - setMeterMarkersMap( - meterMarkers.data?.map((meter: MeterMapDTO) => { - return ( - { - onMeterSelection(meter.id); - }, - }} - > - {meter.serial_number} - - ); - }), - ); - }, [meterMarkers.data]); - return ( - - - - {meterMarkersMap} - - - - - - ({ - color: "red", - dashArray: "5, 10", - weight: 2, - fillOpacity: 0, - })} - /> - - - - - ({ - color: "black", - weight: 3, - fillOpacity: 0, - })} - onEachFeature={(feature, layer) => { - if (feature.properties && feature.properties.TWNSHPLAB) { - layer.bindTooltip(feature.properties.TWNSHPLAB, { - permanent: true, - direction: "center", - className: "geojson-label", + <> + + + + {/* Base Layers */} + + + + {/* Markers Cluster Overlay */} + + { + const count = cluster.getChildCount(); + + return L.divIcon({ + html: `
${count}
`, + className: "", + iconSize: [40, 40], }); - } - }} - /> -
-
-
-
+ }} + > + {meterMarkers.isSuccess && + meterMarkers.data.map((meter: MeterMapDTO) => { + const color = meter.last_pm ? getMeterMarkerColor(meter.last_pm) : "black"; + + return ( + onMeterSelection(meter.id), + }} + icon={L.divIcon({ + className: "", + html: `
`, + })} + > + {meter.serial_number} +
+ ); + })} + + + + {/* Section GeoJSON */} + + + ({ + color: "red", + dashArray: "5, 10", + weight: 2, + fillOpacity: 0, + })} + /> + + + + {/* Township/Range GeoJSON */} + + + ({ + color: "black", + weight: 3, + fillOpacity: 0, + })} + onEachFeature={(feature, layer) => { + if (feature.properties?.TWNSHPLAB) { + layer.bindTooltip(feature.properties.TWNSHPLAB, { + permanent: true, + direction: "center", + className: "geojson-label", + }); + } + }} + /> + + + + + + + {/* Loading and empty states */} + {meterMarkers.isLoading && ( + + Loading meter markers... + + )} + {meterMarkers.isSuccess && meterMarkers?.data.length === 0 && ( + + + No meters found for that search. + + + )} + {/* Error */} + {meterMarkers.isError && ( + + + Failed to load meters: {meterMarkers.error.message} + + + )} + ); } diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 69186caf..39deb5f2 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -1,10 +1,8 @@ import { useState, useEffect } from "react"; import { useDebounce } from "use-debounce"; - -import { Box, Button } from "@mui/material"; +import { Box, Button, Stack } from "@mui/material"; import { DataGrid, GridSortModel, GridColDef } from "@mui/x-data-grid"; import AddIcon from "@mui/icons-material/Add"; - import { MeterListQueryParams, SecurityScope } from "../../../interfaces"; import { SortDirection, @@ -22,12 +20,12 @@ interface MeterSelectionTableProps { setMeterAddMode: Function; } -export default function MeterSelectionTable({ +export const MeterSelectionTable = ({ onMeterSelection, meterSearchQuery, setMeterAddMode, meterStatusFilter, -}: MeterSelectionTableProps) { +}: MeterSelectionTableProps) => { const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); const [meterListQueryParams, setMeterListQueryParams] = useState({ @@ -108,11 +106,10 @@ export default function MeterSelectionTable({ if (meterList.data) { setGridRowCount(meterList.data.total); } - //setGridRowCount(meterList.data?.total ?? 0) // Update the meter count when new list is recieved from API }, [meterList]); return ( - + setMeterAddMode(true)} + - - Add a New Meter - + + ), }, }} /> ); -} +}; diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index 2a7953e2..f8245ba3 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -1,15 +1,16 @@ import { useEffect } from "react"; import { useState } from "react"; import { useLocation } from "react-router-dom"; -import MeterSelection from "./MeterSelection/MeterSelection"; -import MeterDetailsFields from "./MeterDetailsFields"; -import MeterHistory from "./MeterHistory/MeterHistory"; +import { MeterSelection } from "./MeterSelection/MeterSelection"; +import { MeterDetailsFields } from "./MeterDetailsFields"; +import { MeterHistory } from "./MeterHistory/MeterHistory"; -import { Grid, Box } from "@mui/material"; +import { Grid } from "@mui/material"; +import { BackgroundBox } from "../../components/BackgroundBox"; // Main view for the Meters page // Can pass state to this view to pre-select a meter and meter history using React Router useLocation -export default function MetersView() { +export const MetersView = () => { const location = useLocation(); const [selectedMeter, setSelectedMeter] = useState(); const [meterAddMode, setMeterAddMode] = useState(false); @@ -32,30 +33,28 @@ export default function MetersView() { }, [selectedMeter]); return ( - -

Meter Information

+ - + - + - + -
+ ); -} +}; diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx index 163e2a3b..0b08af03 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Box, Typography } from "@mui/material"; +import { Box, CircularProgress, Typography } from "@mui/material"; import Plot from "react-plotly.js"; import { Data } from "plotly.js"; @@ -16,22 +16,6 @@ export const MonitoringWellsPlot = ({ logger_vals: number[]; isLoading: boolean; }) => { - if (isLoading) { - return ( - - Loading... - - ); - } - const data: Partial[] = useMemo( () => [ { @@ -54,26 +38,44 @@ export const MonitoringWellsPlot = ({ ); return ( - - + + {isLoading ? + + + + Loading plot data... + + + : + + } ); }; diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx index 8b19ce89..4ae7dbe4 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { Box, Button, Tooltip } from "@mui/material"; import { DataGrid, GridPagination, GridColDef } from "@mui/x-data-grid"; +import AddIcon from "@mui/icons-material/Add"; import { MonitoredWell, WellMeasurementDTO } from "../../interfaces"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -110,8 +111,15 @@ const Footer = ({ arrow > - diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 23109368..66567801 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -1,16 +1,17 @@ import { useId, useState, useMemo } from "react"; import { - Box, FormControl, Select, MenuItem, InputLabel, Card, CardContent, - Typography, ListSubheader, useTheme, - CardHeader, + Grid, + Alert, + Button, + AlertTitle, } from "@mui/material"; import { useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -37,28 +38,11 @@ import dayjs, { Dayjs } from "dayjs"; import { useFetchWithAuth, useFetchST2 } from "../../hooks"; import { getDataStreamId } from "../../utils/DataStreamUtils"; import { MonitorHeart } from "@mui/icons-material"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { separateAndSortMonitoredWells } from "../../utils"; -const separateAndSortWells = ( - wells: MonitoredWell[] = [], -): [MonitoredWell[], MonitoredWell[]] => { - const sortWells = (w: MonitoredWell[]) => - w.slice().sort((a, b) => { - if (!a.name) return 1; // Move undefined/null names to the bottom - if (!b.name) return -1; - return a.name.localeCompare(b.name); - }); - - const outsideRecorderWells = sortWells( - wells.filter((well) => well.outside_recorder === true), - ); - const regularWells = sortWells( - wells.filter((well) => well.outside_recorder !== true), - ); - - return [outsideRecorderWells, regularWells]; -}; - -export default function MonitoringWellsView() { +export const MonitoringWellsView = () => { const theme = useTheme(); const fetchWithAuth = useFetchWithAuth(); @@ -81,11 +65,7 @@ export default function MonitoringWellsView() { (s: SecurityScope) => s.scope_string === "admin", ); - const { - data: wells, - isLoading: isLoadingWells, - error: errorWells, - } = useQuery<{ items: MonitoredWell[] }, Error, MonitoredWell[]>({ + const monitoredWellsQuery = useQuery<{ items: MonitoredWell[] }, Error, MonitoredWell[]>({ queryKey: ["wells"], queryFn: () => fetchWithAuth({ @@ -111,7 +91,7 @@ export default function MonitoringWellsView() { fetchWithAuth({ method: "GET", route: "/waterlevels", - params: { well_id: wellId }, + params: { well_ids: wellId }, }), enabled: !!wellId, }); @@ -136,7 +116,7 @@ export default function MonitoringWellsView() { const updateMeasurement = useUpdateWaterLevel(() => refetchManual()); const deleteMeasurement = useDeleteWaterLevel(); - const error = errorWells || errorManual || errorSt2; + const error = monitoredWellsQuery.isError || errorManual || errorSt2; const handleSubmitNewMeasurement = (data: NewWellMeasurement) => { if (wellId) { @@ -178,42 +158,48 @@ export default function MonitoringWellsView() { setIsUpdateModalOpen(true); }; - const [outsideRecorderWells, regularWells] = separateAndSortWells(wells); + const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells(monitoredWellsQuery?.data); return ( - - - - Monitored Well Values - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + {error && ( - - An error had occurred while attempting to loading data - + monitoredWellsQuery.refetch()} + > + Retry + + } + > + Error Loading Data + We couldn’t load monitoring wells. Please check your connection or try + again. + )} - Site field.onChange(e.target.value)} + onBlur={field.onBlur} + inputRef={field.ref} + displayEmpty + MenuProps={{ + PaperProps: { style: { maxHeight: 48 * 6.5 + 8, width: 220 } }, + }} + > + + Select a year + + {years.map((year) => ( + + {year} + + ))} + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + +
+ + + + Depth of Water over Time + + + { + const date = dayjs(value); + const isMidnight = date.hour() === 0 && date.minute() === 0; + return isMidnight + ? date.format("MMM D, YYYY") + : date.format("MMM D, YYYY HH:mm"); + } + }]} + yAxis={[{ + reverse: true, + }]} + series={series} + slotProps={{ + legend: { + direction: "horizontal", + position: { + vertical: "bottom", + horizontal: "center", + }, + }, + }} + sx={{ width: "100%", height: "100%" }} + /> + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx new file mode 100644 index 00000000..7fff7868 --- /dev/null +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -0,0 +1,444 @@ +import { useEffect, useMemo } from "react"; +import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; +import { + Autocomplete, + Button, + Card, + CardContent, + FormControlLabel, + Grid, + IconButton, + Switch, + TextField, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; +import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import dayjs, { Dayjs } from "dayjs"; +import { API_URL } from "../../../config"; +import { useAuthHeader } from "react-auth-kit"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { BackgroundBox } from "../../../components/BackgroundBox"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; +import { ControlledSelect } from "../../../components/RHControlled/ControlledSelect"; + +export interface MeterType { + id: number; + brand: string; + series: string | null; + model: string; + size: number; + description: string; + in_use: boolean; +} + +export interface PartType { + id: number; + name: string; + description: string; +} + +export interface Part { + id: number; + part_number: string; + description: string; + vendor: string | null; + count: number; + note: string; + in_use: boolean; + commonly_used: boolean; + price: number | null; + part_type_id: number; + part_type: PartType; + meter_types: MeterType[]; +} + +const schema = yup.object().shape({ + from: yup.mixed().nullable().required("From date is required"), + to: yup + .mixed() + .nullable() + .required("To date is required") + .test("is-after", "'To' date must be after 'From'", function(value) { + const { from } = this.parent; + return !from || !value || dayjs(value).isAfter(dayjs(from)); + }), + part_types: yup + .array() + .of( + yup.object().shape({ + id: yup.number().nullable(), + type: yup + .object() + .shape({ + id: yup.number().nullable(), + name: yup.string().nullable(), + description: yup.string().nullable(), + }) + .nullable(), + }), + ) + .nullable(), + parts: yup + .array() + .of(yup.number().required()) + .min(1, "At least one Part is required"), + in_use: yup.bool().required() +}); + +const defaultSchema = { + from: dayjs(), + to: dayjs(), + part_types: [], + parts: [], + in_use: true +}; + +export const PartsUsedReportView = () => { + const { control, reset, watch } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + + const from = watch("from"); + const to = watch("to"); + const selectedPartIds = watch("parts") ?? []; + const partTypes = watch("part_types"); + const inUse = watch("in_use"); + + const authHeader = useAuthHeader(); + const partsQuery = useQuery({ + queryKey: ["Inventory", "report", "partslist", inUse], + queryFn: async () => { + const response = await fetch(`${API_URL}/parts?in_use=${inUse}`, { + headers: { Authorization: authHeader() }, + }); + if (!response.ok) { + throw new Error("Failed to fetch parts"); + } + return response.json(); + }, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours + }); + + const filteredParts = useMemo(() => { + if (!partsQuery.data) return []; + + if (Array.isArray(partTypes) && partTypes.length > 0) { + const selectedIds = partTypes.map((pt) => pt.id); + return partsQuery.data.filter((p) => + selectedIds.includes(p.part_type_id), + ); + } + + return partsQuery.data; + }, [partsQuery.data, partTypes]); + + useEffect(() => { + const currentParts = watch("parts") ?? []; + const validIds = filteredParts.map((p) => p.id); + const stillValid = currentParts.filter((id) => validIds.includes(id)); + + if (currentParts.length !== stillValid.length) { + // Drop invalid part IDs + reset({ ...watch(), parts: stillValid }); + } + }, [partTypes, filteredParts]); + + const partsUsedQuery = useQuery({ + queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], + queryFn: async () => { + const searchParams = new URLSearchParams({ + from_month: from?.format("YYYY-MM"), + to_month: to?.format("YYYY-MM"), + }); + + selectedPartIds.forEach((id: number) => { + searchParams.append("parts", id.toString()); + }); + + const response = await fetch( + `${API_URL}/parts/used?${searchParams.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch parts used data"); + } + + return response.json(); + }, + enabled: Boolean(from && to && selectedPartIds?.length > 0), + }); + + let runningTotal = 0; + + const rows = partsUsedQuery?.data?.map((part) => { + runningTotal += part.total; + return { + ...part, + running_total: runningTotal, + }; + }); + + const columns: GridColDef[] = [ + { field: "part_number", headerName: "Part", flex: 1 }, + { field: "description", headerName: "Description", flex: 2 }, + { + field: "price", + headerName: "Cost per unit", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + }, + { + field: "quantity", + headerName: "Number of units", + flex: 1, + type: "number", + }, + { + field: "total", + headerName: "Total cost", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + }, + { + field: "running_total", + headerName: "Running Total", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param.toFixed(2)}` : "$0.00", + }, + ]; + + const downloadPDFMutation = useMutation({ + mutationFn: async ({ + from, + to, + parts, + }: { + from: Dayjs; + to: Dayjs; + parts: number[]; + }) => { + const params = new URLSearchParams({ + from_month: from.format("YYYY-MM"), + to_month: to.format("YYYY-MM"), + }); + + parts.forEach((id) => params.append("parts", id.toString())); + + const response = await fetch( + `${API_URL}/parts/used/pdf?${params.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("PDF generation failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "parts_used_report.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const handleDownloadPDF = () => { + if (!from || !to || selectedPartIds.length === 0) return; + + downloadPDFMutation.mutate({ + from, + to, + parts: selectedPartIds, + }); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ + id: option.part_type_id, + type: option.part_type, + })) + .map((item) => [item.id, item]), // key by id + ).values(), + ]} + getOptionLabel={(option: any) => option.type.name} + /> + + + { + // Convert stored IDs to Part objects for the `value` prop + const selectedParts = (partsQuery?.data ?? []).filter( + (part) => field?.value?.includes(part.id), + ); + + return ( + + `${option.part_number} ${option.description}` + } + isOptionEqualToValue={(a: Part, b: Part) => a.id === b.id} + value={selectedParts} + onChange={(_, selectedOptions) => + field.onChange(selectedOptions.map((p) => p.id)) + } + filterOptions={(options: Part[], state: any) => + options.filter((opt) => + `${opt.part_number} ${opt.description}` + .toLowerCase() + .includes(state.inputValue.toLowerCase()), + ) + } + loading={partsQuery.isLoading} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + + + { + return ( + onChange(e.target.checked)} + /> + } + /> + ); + }} + /> + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/WorkOrders/index.tsx b/frontend/src/views/Reports/WorkOrders/index.tsx new file mode 100644 index 00000000..0fca7099 --- /dev/null +++ b/frontend/src/views/Reports/WorkOrders/index.tsx @@ -0,0 +1,166 @@ +import { + FormatListBulletedOutlined, + ArrowBack, + PictureAsPdf, +} from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + TextField, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; +import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; +import ControlledAutocomplete from "../../../components/RHControlled/ControlledAutocomplete"; +import { useForm } from "react-hook-form"; +import { useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import dayjs from "dayjs"; + +const schema = yup.object().shape({ + time: yup.mixed().nullable().required("Date is required"), + techician: yup.string().required("Techician is required"), + source: yup.string().required("Source is required"), +}); + +const defaultSchema = { + time: dayjs(), + techician: "", + source: "", +}; + +export const WorkOrdersReportView = () => { + const techiciansQuery = useQuery({ + queryKey: ["workorders", "report", "techicians"], + queryFn: async () => {}, + }); + const sourceQuery = useQuery({ + queryKey: ["workorders", "report", "source"], + queryFn: async () => {}, + }); + + const { control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + + return ( + + + + Work Orders Report + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + + + + + + + + + + + + + + + + + + { + if (techiciansQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + { + if (sourceQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx new file mode 100644 index 00000000..c7b8c69c --- /dev/null +++ b/frontend/src/views/Reports/index.tsx @@ -0,0 +1,61 @@ +import { + Assessment, + Build, + MonitorHeart, + Plumbing, + Science, +} from "@mui/icons-material"; +import { Box, Card, CardContent } from "@mui/material"; +import { NavLink } from "../../components/NavLink"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; + +export const ReportsView = () => { + return ( + + + + + + {/* + + */} + + + + {/* + + */} + + + + + + ); +}; diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx new file mode 100644 index 00000000..253fe3c6 --- /dev/null +++ b/frontend/src/views/Settings.tsx @@ -0,0 +1,311 @@ +import * as yup from 'yup'; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useForm, Controller } from "react-hook-form"; +import { + Card, + CardContent, + Divider, + Typography, + Box, + // Button, + MenuItem, + TextField, + Grid, + Alert, + ListItemIcon, + Chip, +} 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 { + Build, + FormatListBulletedOutlined, + ScreenshotMonitor, + Construction, + MonitorHeart, + Plumbing, + Assessment, + Science +} from '@mui/icons-material'; +import { BackgroundBox, CustomCardHeader } from "../components"; + +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 schema = yup.object().shape({ + redirectPage: yup.string().optional(), + currentPassword: yup.string().optional(), + newPassword: yup.string().optional(), + confirmPassword: yup + .string() + .oneOf([yup.ref("newPassword"), ""], "Passwords must match"), +}); + +const FALLBACK_REDIRECT = "/"; + +const RoleChip = ({ role }: { role: string }) => { + switch (role) { + case "Admin": { + return ; + } + case "Technician": { + return ; + } + default: { + return ; + } + } +} + +const IsActiveChip = ({ active }: { active: boolean }) => { + return active ? ( + + ) : ( + + ); +} + +export const Settings = () => { + const authUser = useAuthUser(); + const [savedMessage, setSavedMessage] = useState(""); + + // always read the latest from localStorage + const defaultValues = useMemo(() => { + const stored = localStorage.getItem("redirectPage"); + return { + redirectPage: stored ?? FALLBACK_REDIRECT, + currentPassword: "", + newPassword: "", + confirmPassword: "", + }; + }, []); + + const { + control, + handleSubmit, + watch, + // formState: { errors, isValid }, + } = useForm({ + resolver: yupResolver(schema), + mode: "onChange", + defaultValues, + }); + + // 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)."); + } + }, [redirectPage]); + + 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 user = authUser(); + + return ( + + + + + {/* User Info */} + + + User Information + + + + + + Full Name:{" "} + + {user?.full_name ?? "N/A"} + + + + + + Email:{" "} + + {user?.email ?? "N/A"} + + + + + + Username:{" "} + + {user?.username ?? "N/A"} + + + + + + Role: + + + + + + Active: + + + + + + +
+ + Preferences + + ( + + {redirectOptions.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + )} + /> + {/* + + + Password Reset + + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + + + + */} + + {savedMessage && ( + + {savedMessage} + + )} +
+
+
+ ); +}; + diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index f3c26dc6..0f6a66a8 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -1,21 +1,15 @@ import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { - Button, - Card, - CardHeader, - CardContent, - Grid, - TextField, -} from "@mui/material"; +import { Button, Card, CardContent, Grid, InputAdornment, TextField, Tooltip } from "@mui/material"; import { useGetSecurityScopes } from "../../service/ApiServiceNew"; import AddIcon from "@mui/icons-material/Add"; -import SearchIcon from "@mui/icons-material/Search"; +import { Search } from "@mui/icons-material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { SecurityScope } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function PermissionsTable() { +export const PermissionsTable = () => { const securityScopesList = useGetSecurityScopes(); const [permissionSearchQuery, setPermissionSearchQuery] = useState(""); @@ -39,54 +33,61 @@ export default function PermissionsTable() { }, [permissionSearchQuery, securityScopesList.data]); return ( - - - All Permissions - - - } - sx={{ mb: 0, pb: 0 }} + + - - - - {" "} -  Search Permissions - - } - variant="outlined" - size="small" - value={permissionSearchQuery} - onChange={(event: any) => - setPermissionSearchQuery(event.target.value) - } - sx={{ marginBottom: "10px" }} - /> + + + + setPermissionSearchQuery(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + + + ), + }, + }} + disableColumnFilter + /> + - - - Permissions must be configured by a developer - - ), - }, - }} - disableColumnFilter - /> ); -} +}; diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 17e00b9d..8fea20dd 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, FormControl, Grid, @@ -32,6 +31,7 @@ import { } from "../../service/ApiServiceNew"; import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; import { SecurityScope, UserRole } from "../../interfaces"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const RoleResolverSchema: Yup.ObjectSchema = Yup.object().shape({ name: Yup.string().required("Please enter a name."), @@ -42,10 +42,10 @@ interface RoleDetailsCardProps { roleAddMode: boolean; } -export default function RoleDetailsCard({ +export const RoleDetailsCard = ({ selectedRole, roleAddMode, -}: RoleDetailsCardProps) { +}: RoleDetailsCardProps) => { const { handleSubmit, control, @@ -111,21 +111,9 @@ export default function RoleDetailsCard({ return ( - - Create Role - {" "} - - ) : ( -
- Edit Role - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -181,7 +169,7 @@ export default function RoleDetailsCard({ .includes(x.id), ) .map((scope: SecurityScope) => ( - + {scope.scope_string} ))} @@ -219,4 +207,4 @@ export default function RoleDetailsCard({
); -} +}; diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 8623999b..6ccf9994 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -3,26 +3,27 @@ import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, Card, - CardHeader, CardContent, Chip, Grid, + InputAdornment, TextField, } from "@mui/material"; +import { Search } from "@mui/icons-material"; import { useGetRoles } from "../../service/ApiServiceNew"; import AddIcon from "@mui/icons-material/Add"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import SearchIcon from "@mui/icons-material/Search"; import { UserRole } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function RolesTable({ +export const RolesTable = ({ setSelectedRole, setRoleAddMode, }: { setSelectedRole: Function; setRoleAddMode: Function; -}) { +}) => { const rolesList = useGetRoles(); const [roleSearchQuery, setRoleSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -65,63 +66,65 @@ export default function RolesTable({ }, [roleSearchQuery, rolesList.data]); return ( - - - All Roles - - - } - sx={{ mb: 0, pb: 0 }} + + - - - - {" "} -  Search Roles - - } - variant="outlined" - size="small" - value={roleSearchQuery} - onChange={(event: any) => setRoleSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} - /> + + + + setRoleSearchQuery(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + { + setSelectedRole( + rolesList.data?.find( + (role: UserRole) => role.id == selectedRow.row.id, + ), + ); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + ), + }, + }} + disableColumnFilter + /> + - { - setSelectedRole( - rolesList.data?.find( - (role: UserRole) => role.id == selectedRow.row.id, - ), - ); - }} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: ( - - ), - }, - }} - disableColumnFilter - /> ); -} +}; diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 38e8a154..6c94b39e 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -8,7 +8,6 @@ import { Button, Card, CardContent, - CardHeader, Grid, Typography, } from "@mui/material"; @@ -34,6 +33,7 @@ import { ControlledSelect, ControlledSelectNonObject, } from "../../components/RHControlled/ControlledSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ username: Yup.string().required("Please enter a username."), @@ -96,10 +96,10 @@ interface UserDetailsCardProps { // 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 default function UserDetailsCard({ +export const UserDetailsCard = ({ selectedUser, userAddMode, -}: UserDetailsCardProps) { +}: UserDetailsCardProps) => { const rolesList = useGetRoles(); // React hook form for user field values @@ -127,7 +127,7 @@ export default function UserDetailsCard({ enqueueSnackbar("Successfully Created New User!", { variant: "success" }); reset(); } - const onErr = (data: any) => console.log("ERR: ", data); + const onErr = (data: any) => console.error("ERR: ", data); const updateUser = useUpdateUser(onSuccessfulUpdate); const createUser = useCreateUser(onSuccessfulCreate); @@ -177,27 +177,13 @@ export default function UserDetailsCard({ }, [userAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create User - {" "} - - ) : ( -
- Edit User - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -302,4 +288,4 @@ export default function UserDetailsCard({
); -} +}; diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index 75bf73a2..9855882c 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -1,13 +1,14 @@ -import { Box, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; -import UsersTable from "./UsersTable"; -import UserDetailsCard from "./UserDetailsCard"; +import { UsersTable } from "./UsersTable"; +import { UserDetailsCard } from "./UserDetailsCard"; import { User, UserRole } from "../../interfaces"; -import RolesTable from "./RolesTable"; -import RoleDetailsCard from "./RoleDetailsCard"; -import PermissionsTable from "./PermissionsTable"; +import { RolesTable } from "./RolesTable"; +import { RoleDetailsCard } from "./RoleDetailsCard"; +import { PermissionsTable } from "./PermissionsTable"; +import { BackgroundBox } from "../../components/BackgroundBox"; -export default function UserManagementView() { +export const UserManagementView = () => { const [selectedUser, setSelectedUser] = useState(); const [userAddMode, setUserAddMode] = useState(true); const [selectedRole, setSelectedRole] = useState(); @@ -23,60 +24,36 @@ export default function UserManagementView() { }, [selectedRole]); return ( - -

Manage Users

- + - - - - - - - + + + + + + + + - - - - - - - + + - - - - + + -
+ ); -} +}; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 7c928aac..ecc38d27 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -3,27 +3,29 @@ import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, Card, - CardHeader, CardContent, Chip, Grid, + InputAdornment, TextField, + Typography, } from "@mui/material"; +import { Search } from "@mui/icons-material"; import { useGetUserAdminList } from "../../service/ApiServiceNew"; import AddIcon from "@mui/icons-material/Add"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import SearchIcon from "@mui/icons-material/Search"; import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function UsersTable({ +export const UsersTable = ({ setSelectedUser, setUserAddMode, }: { setSelectedUser: Function; setUserAddMode: Function; -}) { +}) => { const usersList = useGetUserAdminList(); const [userSearchQuery, setUserSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -85,82 +87,80 @@ export default function UsersTable({ }, [userSearchQuery, usersList.data, isActiveFilter, isTechnicianFilter]); return ( - - - All Users - - - } - sx={{ mb: 0, pb: 0 }} + + - - - + + + - {" "} -  Search Users - - } + sx={{ m: 0, width: '100%', maxWidth: '75rem' }} + placeholder="Search Users..." variant="outlined" size="small" value={userSearchQuery} onChange={(event: any) => setUserSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - -
-
Choose Filters:
- - setIsActiveFilter(state) - } - /> - - setIsTechnicianFilter(state) - } - /> -
+ + Choose Filters: + + setIsActiveFilter(state) + } + /> + + setIsTechnicianFilter(state) + } + />
- { - setSelectedUser( - usersList.data?.find( - (user: User) => user.id == selectedRow.row.id, - ), - ); - }} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: ( - - ), - }, - }} - disableColumnFilter - /> + + { + setSelectedUser( + usersList.data?.find( + (user: User) => user.id == selectedRow.row.id, + ), + ); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + ), + }, + }} + disableColumnFilter + /> +
-
+
); -} +}; diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index 38b3ae5e..e64c8708 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -5,7 +5,6 @@ import { Button, Card, CardContent, - CardHeader, Checkbox, FormControlLabel, Grid, @@ -42,6 +41,7 @@ import { MergeWellModal } from "../../components/MergeWellModal"; import { useAuthUser } from "react-auth-kit"; import { SecurityScope } from "../../interfaces"; import ControlledCheckbox from "../../components/RHControlled/ControlledCheckbox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ use_type: Yup.object().required("Please select a use type."), @@ -51,19 +51,24 @@ const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ }), }); -export default function WellDetailsCard( - {selectedWell, wellAddMode,}: {selectedWell?: Well; wellAddMode: boolean;}) { - const { +export const WellDetailsCard = ({ + selectedWell, + wellAddMode, +}: { + selectedWell?: Well; + wellAddMode: boolean; +}) => { + const { handleSubmit, control, setValue, reset, watch, formState: { errors }, - } = useForm({ - resolver: yupResolver(WellResolverSchema), - defaultValues: {location: { latitude: 0, longitude: 0 }} - }); + } = useForm({ + resolver: yupResolver(WellResolverSchema), + defaultValues: { location: { latitude: 0, longitude: 0 } }, + }); const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -110,9 +115,7 @@ export default function WellDetailsCard( }, [wellAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; // Modal related functions const [isWellMergeModalOpen, setIsWellMergeModalOpen] = React.useState(false); @@ -121,21 +124,9 @@ export default function WellDetailsCard( return ( - - Create Well - {" "} - - ) : ( -
- Edit Well - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -196,7 +187,12 @@ export default function WellDetailsCard( {setValue("chloride_group_id", e.target.checked ? 1 : null);}} + onChange={(e) => { + setValue( + "chloride_group_id", + e.target.checked ? 1 : null, + ); + }} size="small" /> } @@ -214,8 +210,6 @@ export default function WellDetailsCard( label="Region ID" type="number" inputProps={{ min: 1, max: 128 }} - //error={errors?.chloride_group_id?.message != undefined} - //helperText={errors?.chloride_group_id?.message} /> @@ -354,4 +348,4 @@ export default function WellDetailsCard(
); -} +}; diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index 09f0545d..9927a275 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -1,8 +1,9 @@ -import { Box, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; -import WellsTable from "./WellsTable"; +import { WellsTable } from "./WellsTable"; import { Well } from "../../interfaces"; -import WellDetailsCard from "./WellDetailsCard"; +import { WellDetailsCard } from "./WellDetailsCard"; +import { BackgroundBox } from "../../components/BackgroundBox"; export default function WellManagementView() { const [wellAddMode, setWellAddMode] = useState(true); @@ -13,23 +14,24 @@ export default function WellManagementView() { }, [selectedWell]); return ( - -

Manage Wells

- - - + + + - + -
+ ); } diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index 836f616a..89992253 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -1,71 +1,171 @@ -import { useState, useEffect } from "react"; +import { useEffect } from "react"; import { useDebounce } from "use-debounce"; -import { MapContainer, Marker, TileLayer } from "react-leaflet"; +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 { useGetWellLocations } from "../../service/ApiServiceNew"; import { Well } from "../../interfaces"; +import { Box, Typography } from "@mui/material"; +import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, WellMapLegend } from "../../components"; -import icon from "leaflet/dist/images/marker-icon.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; -const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); - -L.Marker.prototype.options.icon = DefaultIcon; - -interface WellSelectionMapProps { - wellSearchQueryProp: string; - setSelectedWell: Function; -} +// @ts-ignore +import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; +import "@changey/react-leaflet-markercluster/dist/styles.min.css"; export default function WellSelectionMap({ setSelectedWell, wellSearchQueryProp, -}: WellSelectionMapProps) { +}: { + wellSearchQueryProp: string; + setSelectedWell: Function; +}) { const [wellSearchDebounced] = useDebounce(wellSearchQueryProp, 250); - const [wellMarkersMap, setwellMarkersMap] = useState([]); - - const mapStyle = { - height: "500px", - }; - - const wellMarkers: any = useGetWellLocations(wellSearchDebounced); - const onClickMarker = (well: Well) => { - setSelectedWell(well); - }; + const wellQuery = useGetWellLocations(wellSearchDebounced); useEffect(() => { - setwellMarkersMap( - wellMarkers.data?.map((well: Well) => { - return ( - { - onClickMarker(well); - }, - }} - > - ); - }), - ); - }, [wellMarkers.data]); + if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { + wellQuery.fetchNextPage(); + } + }, [wellQuery.hasNextPage, wellQuery.isFetchingNextPage]); + + const wellMarkers = wellQuery.data?.pages.flat() ?? []; return ( - - - {wellMarkersMap} - + <> + + + + {/* Base Layers */} + + + + + {/* Wells Cluster Overlay */} + + { + const count = cluster.getChildCount(); + return L.divIcon({ + html: `
${count}
`, + className: "", + iconSize: [40, 40], + }); + }} + > + {wellQuery.isSuccess && + wellMarkers.map((well: Well) => ( + setSelectedWell(well), + }} + icon={well.chloride_group_id != null ? redIcon : blueIcon} + > + + {well.name || well.ra_number || well.id} + + + ))} +
+ +
+
+
+
+ {/* Loading first page */} + {wellQuery.isLoading && ( + + Loading well markers... + + )} + {/* Loading additional pages */} + {wellQuery.isFetchingNextPage && ( + + Loading more wells... + + )} + {wellQuery.isSuccess && wellMarkers.length === 0 && ( + + + No wells found for that search. + + + )} + {/* Error */} + {wellQuery.isError && ( + + + Failed to load wells: {wellQuery.error.message} + + + )} + ); } diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index 35583c8b..570fed78 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -1,4 +1,4 @@ -import { Box, Button } from "@mui/material"; +import { Box, Button, Stack } from "@mui/material"; import { Link } from "react-router-dom"; import { DataGrid, GridColDef, GridSortModel } from "@mui/x-data-grid"; import React, { useEffect, useState } from "react"; @@ -47,12 +47,23 @@ export default function WellSelectionTable({ .includes("admin"); const cols: GridColDef[] = [ - { field: "ra_number", headerName: "RA Number", width: 100 }, - { field: "osetag", headerName: "OSE Tag", width: 100 }, + { + field: "ra_number", + headerName: "RA Number", + flex: 1, + minWidth: 100, + }, + { + field: "osetag", + headerName: "OSE Tag", + flex: 1, + minWidth: 100, + }, { field: "water_users", headerName: "Water Users", - width: 150, + flex: 1, + minWidth: 150, sortable: false, valueGetter: (_, row: Well) => row.meters.map((meter) => meter.water_users).join(", "), @@ -60,19 +71,22 @@ export default function WellSelectionTable({ { field: "use_type", headerName: "Use Type", - width: 150, + flex: 1, + minWidth: 150, valueGetter: (_, row) => row.use_type?.use_type, }, { field: "location", headerName: "TRSS", - width: 150, + flex: 1, + minWidth: 150, valueGetter: (_, row) => row.location?.trss, }, { field: "meters", headerName: "Meters", - width: 200, + flex: 2, + minWidth: 200, sortable: false, renderCell: (params) => { const meters = params.value as Well["meters"]; @@ -110,7 +124,7 @@ export default function WellSelectionTable({ // Ternaries in sorting make sure that the view defaults to showing the backend's defaults return ( - + setWellAddMode(true)} - disabled={!hasAdminScope} + button: hasAdminScope && ( + - - Add a New Well - + + ), }, }} diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index 97552266..72d19fd2 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -1,74 +1,66 @@ import { useState } from "react"; - import { Card, - CardHeader, CardContent, Grid, TextField, Tab, Tabs, Box, + InputAdornment, } from "@mui/material"; - import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import SearchIcon from "@mui/icons-material/Search"; - +import { Search } from "@mui/icons-material"; import TabPanel from "../../components/TabPanel"; import WellSelectionTable from "./WellSelectionTable"; import WellSelectionMap from "./WellSelectionMap"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -interface WellsTableProps { - setSelectedWell: Function; - setWellAddMode: Function; -} - -export default function WellsTable({ +export const WellsTable = ({ setSelectedWell, setWellAddMode, -}: WellsTableProps) { +}: { + setSelectedWell: Function; + setWellAddMode: Function; +}) => { const [wellSearchQuery, setWellSearchQuery] = useState(""); - const [currentTabIndex, setCurrentTabIndex] = useState(0); const handleTabChange = (_: React.SyntheticEvent, newTabIndex: number) => setCurrentTabIndex(newTabIndex); return ( - - - All Wells - - - } - sx={{ mb: 0, pb: 0 }} + + - - - + + + - + - {" "} -  Search Wells - - } + sx={{ m: 0, pl: 2, width: '100%', maxWidth: '75rem' }} + placeholder="Search Wells..." variant="outlined" size="small" value={wellSearchQuery} onChange={(event: any) => setWellSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - + - ); -} +}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 8ec9497a..3056766b 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -1,353 +1,503 @@ -/* -This is the work orders table. -I anticipate this component will be self-contained including the ability to add a new row. -*/ - -import React, { useEffect, useState } from 'react'; -import DeletedIcon from '@mui/icons-material/Delete'; -import AddIcon from '@mui/icons-material/Add'; -import HandymanIcon from '@mui/icons-material/Handyman'; -import { - DataGrid, - GridColDef, - GridRowModel, - GridActionsCellItem, - GridActionsCellItemProps, - GridRowParams, - GridRowId, - GridFilterItem -} from '@mui/x-data-grid'; -import { useGetWorkOrders, useUpdateWorkOrder, useGetUserList, useDeleteWorkOrder, useCreateWorkOrder } from '../../service/ApiServiceNew'; -import { WorkOrderStatus } from '../../enums'; -import MeterSelection from '../../components/MeterSelection'; -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, 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'; +import { useEffect, useState } from "react"; +import DeletedIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import HandymanIcon from "@mui/icons-material/Handyman"; +import { + DataGrid, + GridColDef, + GridRowModel, + GridActionsCellItem, + GridActionsCellItemProps, + GridRenderCellParams, + GridRowId, + GridFilterItem, +} from "@mui/x-data-grid"; +import { + useGetWorkOrders, + useUpdateWorkOrder, + useGetUserList, + useDeleteWorkOrder, + 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] = React.useState(false); - - return ( - - setOpen(true)} /> - setOpen(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - {deleteMessage} - - - This action cannot be undone. - - - - - - - - - ); + deleteUser, + deleteMessage, + ...props +}: GridActionsCellItemProps & { + deleteUser: () => void; + deleteMessage?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} /> + setOpen(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {deleteMessage} + + + This action cannot be undone. + + + + + + + + + ); } interface NewWorkOrderModalProps { - openNewWorkOrderModal: boolean, - closeNewWorkOrderModal: () => void, - submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void + openNewWorkOrderModal: boolean; + closeNewWorkOrderModal: () => void; + submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; } -function NewWorkOrderModal({openNewWorkOrderModal, closeNewWorkOrderModal, submitNewWorkOrder}: NewWorkOrderModalProps) { - const [workOrderTitle, setWorkOrderTitle] = useState(''); - const [workOrderMeter, setWorkOrderMeter] = useState(); - const [meterSelectionError, setMeterSelectionError] = useState(false); - const [titleError, setTitleError] = useState(false); +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 - } + 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(); + //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(''); - } + //Reset the form + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + } - function handleCancel() { - closeNewWorkOrderModal(); - setWorkOrderMeter(undefined); - setWorkOrderTitle(''); - } - - return ( - - Create a New Work Order - - - To create a new work order, please select a meter and title. Other fields can be edited as needed after creation. - - - setWorkOrderTitle(event.target.value)} - error={titleError} - helperText={titleError ? "Title cannot be empty" : ""} - /> - - - - - - - ) + const handleCancel = () => { + closeNewWorkOrderModal(); + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + }; + + return ( + + Create a New Work Order + + + To create a new work order, please select a meter and title. Other + fields can be edited as needed after creation. + + + setWorkOrderTitle(event.target.value)} + error={titleError} + helperText={titleError ? "Title cannot be empty" : ""} + /> + + + + + + + ); } export default function WorkOrdersTable() { - const [workOrderFilters, setWorkOrderFilters] = useState([WorkOrderStatus.Open, WorkOrderStatus.Review]); - const workOrderList = useGetWorkOrders(workOrderFilters); - const updateWorkOrder = useUpdateWorkOrder(); - const deleteWorkOrder = useDeleteWorkOrder(()=>console.log("Work order deleted")); - const createWorkOrder = useCreateWorkOrder(); - const userList = useGetUserList(); + const [workOrderFilters, setWorkOrderFilters] = useState([ + WorkOrderStatus.Open, + WorkOrderStatus.Review, + ]); + const workOrderList = useGetWorkOrders(workOrderFilters, { + refetchInterval: false, + }); + const updateWorkOrder = useUpdateWorkOrder(); + const deleteWorkOrder = useDeleteWorkOrder(() => + console.log("Work order deleted"), + ); + const createWorkOrder = useCreateWorkOrder(); + const userList = useGetUserList(); - const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = useState(false); + const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = + useState(false); - //Current user needed for various changes to UI based on user role - const authUser = useAuthUser() - const hasAdminScope = authUser()?.user_role.security_scopes.map((scope: SecurityScope) => scope.scope_string).includes('admin') - const current_user_name = getUserFromID(authUser()?.id) - var initialFilter: GridFilterItem[] = [] //No filter if admin - var status_options = ['Open', 'Review', 'Closed']; + //Current user needed for various changes to UI based on user role + const authUser = useAuthUser(); + const hasAdminScope = authUser() + ?.user_role.security_scopes.map( + (scope: SecurityScope) => scope.scope_string, + ) + .includes("admin"); - //Change a few defaults depending on if admin or not - if (!hasAdminScope){ - initialFilter = [{field: 'assigned_user_id', operator: 'is', value: current_user_name}]; - status_options = ['Open', 'Review']; - }else{ - //Filter by Status - //Unlike with the technicians, this filters on the frontend in case the admin wants to see all work orders - initialFilter = [{field: 'status', operator: 'not', value: 'Closed'}]; - } + const getUserFromID = (id: number | undefined) => { + return userList.data?.find((user) => user.id === id)?.full_name ?? ""; + }; - //Refresh work order list once a minute - useEffect(() => { - const interval = setInterval(() => { - workOrderList.refetch(); - }, 60000); - return () => clearInterval(interval); - }, [workOrderList]); - - //Update list of work orders if technician level to only show open and review. - //useEffect prevents this from running on every render - useEffect(() => { - if (hasAdminScope) { - setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review, WorkOrderStatus.Closed]); - } else { - setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review]); - } - }, [hasAdminScope]); // Dependency array ensures this runs only when hasAdminScope changes + const getUserIDfromName = (name: string) => { + return userList.data?.find((user) => user.full_name === name)?.id ?? 0; + }; - function getUserFromID(id: number|undefined) { - return userList.data?.find(user => user.id === id)?.full_name ?? ""; - } - - function getUserIDfromName(name: string) { - return userList.data?.find(user => user.full_name === name)?.id ?? 0; - } + const current_user_name = getUserFromID(authUser()?.id); + var initialFilter: GridFilterItem[] = []; //No filter if admin + var status_options = ["Open", "Review", "Closed"]; - function handleRowUpdate(updatedRow: GridRowModel, originalRow: GridRowModel): Promise { - //Determine what field has changed and update the work order - const updatedField = Object.keys(updatedRow).find(key => updatedRow[key] !== originalRow[key]); - let field_data = null; - - //If field is assigned_user_id, convert the name to an id - if (updatedField === 'assigned_user_id') { - field_data = getUserIDfromName(updatedRow.assigned_user_id as string); - } else { - field_data = updatedRow[updatedField as string]; - } - - const work_order_update = {work_order_id: updatedRow.work_order_id, [updatedField as string]: field_data}; - console.log("Updating work order", work_order_update); + //Change a few defaults depending on if admin or not + if (!hasAdminScope) { + initialFilter = [ + { field: "assigned_user_id", operator: "is", value: current_user_name }, + ]; + status_options = ["Open", "Review"]; + } else { + //Filter by Status + //Unlike with the technicians, this filters on the frontend in case the admin wants to see all work orders + initialFilter = [{ field: "status", operator: "not", value: "Closed" }]; + } - //Create a promise to update the work order - return updateWorkOrder.mutateAsync(work_order_update) + //Update list of work orders if technician level to only show open and review. + //useEffect prevents this from running on every render + useEffect(() => { + if (hasAdminScope) { + setWorkOrderFilters([ + WorkOrderStatus.Open, + WorkOrderStatus.Review, + WorkOrderStatus.Closed, + ]); + } else { + setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review]); } + }, [hasAdminScope]); // Dependency array ensures this runs only when hasAdminScope changes - function handleProcessRowUpdateError(error: Error): void { - console.error("Error updating work order", error); - } - - function handleDeleteClick(id: GridRowId) { - let deletepromise = deleteWorkOrder.mutateAsync(id as number); - deletepromise.then(() => { - //Get the updated rows - workOrderList.refetch(); - console.log("Work order deleted"); - }); - } + const handleRowUpdate = ( + updatedRow: GridRowModel, + originalRow: GridRowModel, + ): Promise => { + //Determine what field has changed and update the work order + const updatedField = Object.keys(updatedRow).find( + (key) => updatedRow[key] !== originalRow[key], + ); + let field_data = null; - function handleNewWorkOrder(newWorkOrder: NewWorkOrder) { - console.log("Creating new work order", newWorkOrder); - createWorkOrder.mutateAsync(newWorkOrder).then(() => { - //Get the updated rows - workOrderList.refetch(); - console.log("Work order created"); - }); + //If field is assigned_user_id, convert the name to an id + if (updatedField === "assigned_user_id") { + field_data = getUserIDfromName(updatedRow.assigned_user_id as string); + } else { + field_data = updatedRow[updatedField as string]; } - // Define the columns for the table - const columns: GridColDef[] = [ - { field: 'work_order_id', headerName: 'ID', width: 50 }, //Note next line... for some reason this value comes in from the API as a string, not a date - { field: 'date_created', headerName: 'Date', width: 100, valueGetter: (value) => new Date(value), valueFormatter: (value: Date) => value.toLocaleDateString()}, - { - field: 'meter_serial', - headerName: 'Meter', - width: 100, - renderCell: (params) => { - return {params.value} - } - }, - { field: 'title', headerName: 'Title', width: 200, editable: hasAdminScope}, - { field: 'description', headerName: 'Description', width: 300, editable: hasAdminScope}, - { field: 'creator', headerName: 'Created By', width: 150, editable: hasAdminScope}, - { field: 'status', headerName: 'Status', width: 125, type: 'singleSelect', valueOptions: status_options, editable: true}, - { field: 'notes', headerName: 'Notes', width: 300, editable: true}, - { - field: 'associated_activities', - headerName: 'Activity IDs', - width: 150, - renderCell: (params) => { - const activities = params.value as MeterActivity[] ?? []; - const links = activities.map((activity, index) => ( - - - {activity.id} - - {index < params.value.length - 1 ? ', ' : ''} - - )); - return <>{links}; - }, - editable: false - }, - { - field: 'assigned_user_id', - headerName: 'Technician Assigned', - width: 200, - valueGetter: (id) => getUserFromID(id as number), - type: 'singleSelect', - valueOptions: userList.data?.map(user => user.full_name) ?? [], - editable: hasAdminScope - }, - { - field: 'actions', - headerName: 'Actions', - width: 100, - type: 'actions', - getActions: (params: GridRowParams) => { - return params.row.status === 'Open' ? [ - , - } - deleteMessage={`Delete work order ${params.id}?`} - label="Delete" - deleteUser={() => handleDeleteClick(params.id)} - showInMenu={false} - disabled={hasAdminScope ? false : true} - />, - ]:[ - } - deleteMessage={`Delete work order ${params.id}?`} - label="Delete" - deleteUser={() => handleDeleteClick(params.id)} - showInMenu={false} - disabled={hasAdminScope ? false : true} - />, - ]; - } - }, - ]; + const work_order_update = { + work_order_id: updatedRow.work_order_id, + [updatedField as string]: field_data, + }; + + //Create a promise to update the work order + return updateWorkOrder.mutateAsync(work_order_update); + }; + + const handleProcessRowUpdateError = (error: Error): void => { + console.error("Error updating work order", error); + }; + + const handleDeleteClick = (id: GridRowId) => { + let deletepromise = deleteWorkOrder.mutateAsync(id as number); + deletepromise.then(() => { + //Get the updated rows + workOrderList.refetch(); + }); + }; + + const handleNewWorkOrder = (newWorkOrder: NewWorkOrder) => { + createWorkOrder.mutateAsync(newWorkOrder).then(() => { + workOrderList.refetch(); + }); + }; - return ( -
- 'auto'} - getRowId={(row) => row.work_order_id} - columns={columns} - initialState={ - { - columns: {columnVisibilityModel: { - work_order_id: false, - creator: hasAdminScope, - associated_activities: hasAdminScope, - assigned_user_id: hasAdminScope - }}, - filter: {filterModel: {items: initialFilter}}, - } + const columns: GridColDef[] = [ + { + field: "work_order_id", + headerName: "ID", + flex: 1, + minWidth: 50 + }, + { + field: "date_created", + headerName: "Date", + flex: 1, + minWidth: 100, + valueGetter: (value) => new Date(value), + valueFormatter: (value: Date) => value.toLocaleDateString(), + }, + { + field: "meter_serial", + headerName: "Meter", + flex: 1, + minWidth: 100, + renderCell: (params) => { + return ( + + {params.value} + + ); + }, + }, + { + field: "title", + headerName: "Title", + flex: 2, + minWidth: 200, + editable: hasAdminScope, + }, + { + field: "description", + headerName: "Description", + flex: 2, + minWidth: 300, + editable: hasAdminScope, + }, + { + field: "creator", + headerName: "Created By", + flex: 2, + minWidth: 150, + editable: hasAdminScope, + }, + { + field: "status", + headerName: "Status", + flex: 1, + minWidth: 125, + type: "singleSelect", + valueOptions: status_options, + editable: true, + }, + { field: "notes", headerName: "Notes", width: 300, editable: true }, + { + field: "associated_activities", + headerName: "Activity IDs", + flex: 1, + minWidth: 150, + renderCell: (params) => { + const activities = (params.value as MeterActivity[]) ?? []; + const links = activities.map((activity, index) => ( + + + {activity.id} + + {index < params.value.length - 1 ? ", " : ""} + + )); + return <>{links}; + }, + editable: false, + }, + { + field: "assigned_user_id", + headerName: "Technician Assigned", + flex: 2, + minWidth: 200, + valueGetter: (id: number) => getUserFromID(id), + type: "singleSelect", + valueOptions: userList.data?.map((user) => user.full_name) ?? [], + editable: hasAdminScope, + }, + { + field: "location_name", + headerName: "Location Name", + flex: 2, + minWidth: 200, + renderCell: (params) => { + const activities = params.row.associated_activities ?? []; + return activities.length > 0 ? activities[0].location_name : ""; + }, + }, + { + field: "water_users", + headerName: "Water Users", + flex: 2, + minWidth: 200, + renderCell: (params) => { + const activities = params.row.associated_activities ?? []; + return activities.length > 0 && activities[0].water_users + ? activities[0].water_users + : ""; + }, + }, + { + field: "actions", + headerName: "Actions", + flex: 1, + minWidth: 100, + sortable: false, + renderCell: (params: GridRenderCellParams) => { + const isOpen = params.row.status === "Open"; + + return ( + + {isOpen && ( + setIsNewWorkOrderModalOpen(true)}> - Add a New Work Order - - }}} - /> - setIsNewWorkOrderModalOpen(false)} - submitNewWorkOrder={handleNewWorkOrder} + aria-label="Edit Activity" + > + + + )} + } + deleteMessage={`Delete work order ${params.id}?`} + label="Delete" + deleteUser={() => handleDeleteClick(params.id)} + showInMenu={false} + disabled={!hasAdminScope} /> -
- ); -}; +
+ ); + }, + }, + ]; + + return ( + + "auto"} + getRowId={(row) => row.work_order_id} + columns={columns} + disableColumnResize={false} + initialState={{ + columns: { + columnVisibilityModel: { + work_order_id: false, + creator: hasAdminScope, + associated_activities: hasAdminScope, + assigned_user_id: hasAdminScope, + }, + }, + filter: { filterModel: { items: initialFilter } }, + }} + processRowUpdate={handleRowUpdate} + onProcessRowUpdateError={handleProcessRowUpdateError} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: hasAdminScope && ( + + + + ), + }, + }} + /> + setIsNewWorkOrderModalOpen(false)} + submitNewWorkOrder={handleNewWorkOrder} + /> + + ); +} diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx index ff2f635f..c3ad9394 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersView.tsx @@ -1,25 +1,21 @@ -import { Box, Card, CardContent, CardHeader } from "@mui/material"; +import { Card, CardContent } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; - import WorkOrdersTable from "./WorkOrdersTable"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; export default function WorkOrdersView() { return ( - - - - Work Orders - - - } - sx={{ mb: 0, pb: 0 }} + + + - + ); } diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts new file mode 100644 index 00000000..cde7f1e1 --- /dev/null +++ b/frontend/src/views/index.ts @@ -0,0 +1,3 @@ +export * from './Home' +export * from './Login' +export * from './Settings' diff --git a/meter-manager.service b/meter-manager.service new file mode 100644 index 00000000..eaa5bd2a --- /dev/null +++ b/meter-manager.service @@ -0,0 +1,24 @@ +# /etc/systemd/system/meter-manager.service + +[Unit] +Description=Meter Manager Docker Compose Daemon +Requires=docker.service +After=docker.service + +[Service] +User=deploy +WorkingDirectory=/home/deploy/WaterManagerDB + +# Pick compose file based on ENV (METER_MANAGER_ENV) +# Default is production +Environment="METER_MANAGER_ENV=production" + +# Run in foreground so systemd tracks it +ExecStart=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml up +ExecStop=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml down + +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/migrations/20250505192557_add_price_field_in_Parts.down.sql b/migrations/20250505192557_add_price_field_in_Parts.down.sql new file mode 100644 index 00000000..30b39627 --- /dev/null +++ b/migrations/20250505192557_add_price_field_in_Parts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Parts" +DROP COLUMN price; diff --git a/migrations/20250505192557_add_price_field_in_Parts.up.sql b/migrations/20250505192557_add_price_field_in_Parts.up.sql new file mode 100644 index 00000000..cffc9d42 --- /dev/null +++ b/migrations/20250505192557_add_price_field_in_Parts.up.sql @@ -0,0 +1,5 @@ +-- Add a new column named "price" to the "Parts" table +-- The field should come after the "vendor" field and be of type "decimal(10,2)" + +ALTER TABLE "Parts" +ADD COLUMN "price" DECIMAL(10,2); diff --git a/scripts/update_price_from_note.sql b/scripts/update_price_from_note.sql new file mode 100644 index 00000000..a2508649 --- /dev/null +++ b/scripts/update_price_from_note.sql @@ -0,0 +1,15 @@ +WITH extracted_prices AS ( + SELECT + id, + (REGEXP_MATCHES(note, '\$(?!.*:)([0-9]+\.[0-9]+)'))[1]::NUMERIC AS extracted_price + FROM "Parts" + WHERE note ~ '\$(?!.*:)[0-9]+\.[0-9]+' +) +UPDATE "Parts" +SET price = extracted_prices.extracted_price +FROM extracted_prices +WHERE "Parts".id = extracted_prices.id; + +UPDATE "Parts" +SET price = NULL +WHERE note NOT LIKE '%$%';