diff --git a/.gitignore b/.gitignore index 483a01fe..f768459b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +.python-version # PyInstaller # Usually these files are written by a python script from a template @@ -227,4 +228,4 @@ cython_debug/ /api/backupdb/*.sql # dependencies /node_modules -/frontend/node_modules \ No newline at end of file +/frontend/node_modules diff --git a/LICENSE b/LICENSE index 261eeb9e..a1eee53f 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2023 New Mexico Water Data Initiative Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/__init__.py b/api/__init__.py index 72f778cc..e69de29b 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,18 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== - - -# ============= EOF ============================================= diff --git a/api/backupdb/restore.sh b/api/backupdb/restore.sh deleted file mode 100644 index edfb5c15..00000000 --- a/api/backupdb/restore.sh +++ /dev/null @@ -1 +0,0 @@ -pg_restore -d appdb_local appdb.sql \ No newline at end of file diff --git a/api/config.py b/api/config.py index 879ce00b..a5011828 100644 --- a/api/config.py +++ b/api/config.py @@ -30,4 +30,3 @@ class Settings: settings = Settings() -# ============= EOF ============================================= diff --git a/api/dbsetup.py b/api/dbsetup.py deleted file mode 100644 index f9418045..00000000 --- a/api/dbsetup.py +++ /dev/null @@ -1,236 +0,0 @@ -# # =============================================================================== -# This script builds the database from scratch and so should only be run as needed -# # =============================================================================== - -import os -import api.models -from api.security import get_password_hash -from sqlalchemy import create_engine -from sqlalchemy.sql import text -from api.session import SessionLocal -from .config import settings - -# Set up a connection -SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL -engine = create_engine(SQLALCHEMY_DATABASE_URL) - -print("Setting up the database") -api.models.main_models.Base.metadata.create_all(engine) - -# Add initial users, roles, and scopes -db = SessionLocal() - -SecurityScopes = api.models.security_models.SecurityScopes -UserRoles = api.models.security_models.UserRoles -Users = api.models.security_models.Users - -admin_scope = SecurityScopes(scope_string="admin", description="Admin-specific scope.") -meter_write_scope = SecurityScopes( - scope_string="meter:write", description="Write meters" -) -activities_write_scope = SecurityScopes( - scope_string="activities:write", description="Write activities" -) -well_measurements_write_scope = SecurityScopes( - scope_string="well_measurement:write", - description="Write well measurements, i.e. Water Levels and Chlorides", -) -reports_run_scope = SecurityScopes( - scope_string="reports:run", description="Run reports" -) -read_scope = SecurityScopes(scope_string="read", description="Read all data.") -ose_scope = SecurityScopes(scope_string="ose", description="Scope given to the OSE") - -technician_role = UserRoles( - name="Technician", - security_scopes=[ - read_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - ], -) -admin_role = UserRoles( - name="Admin", - security_scopes=[ - read_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - ose_scope, - admin_scope, - ], -) -ose_role = UserRoles( - name="OSE", - security_scopes=[read_scope, ose_scope], -) - -admin_user = Users( - full_name="NMWDI Admin", - username="nmwdi_admin", - email="johndoe@example.com", - hashed_password=get_password_hash("testthisapp"), - user_role=technician_role, -) - -db.add_all( - [ - admin_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - read_scope, - ose_scope, - technician_role, - admin_role, - ose_role, - admin_user, - ] -) - -db.commit() -db.close() - - -# Load seed data from CSV -# Follows - https://stackoverflow.com/questions/31394998/using-sqlalchemy-to-load-csv-file-into-a-database -# Get the psycopg2 connector - enables running of lower level functions -conn = engine.raw_connection() -cursor = conn.cursor() - -with open("../PVACDdb_migration/csv_data/tables/metertypes.csv", "r") as f: - qry = 'COPY "MeterTypeLU"(id,brand,series,model_number,size,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/NoteTypeLU.csv", "r") as f: - qry = 'COPY "NoteTypeLU"(id,note,details,slug) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/ServiceTypeLU.csv", "r") as f: - qry = 'COPY "ServiceTypeLU"(id,service_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/landowners.csv", "r") as f: - qry = 'COPY "LandOwners"(organization,address,city,state,zip,phone,mobile,note,id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/meterstatus.csv", "r") as f: - qry = 'COPY "MeterStatusLU"(id,status_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/observedproperties.csv", "r") as f: - qry = 'COPY "ObservedPropertyTypeLU"(id,name,description,context) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/units.csv", "r") as f: - qry = 'COPY "Units"(id,name,name_short,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/propertyunits.csv", "r") as f: - qry = 'COPY "PropertyUnits"(property_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/locationtypeLU.csv", "r") as f: - qry = 'COPY "LocationTypeLU"(id,type_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/locations.csv", "r") as f: - qry = 'COPY "Locations"(id,name,type_id,latitude,longitude,trss) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/welluseLU.csv", "r") as f: - qry = 'COPY "WellUseLU"(id,use_type,code,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/wells.csv", "r") as f: - qry = 'COPY "Wells"(id,name,use_type_id,location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/meters.csv", "r") as f: - qry = 'COPY "Meters"(serial_number,meter_type_id,status_id,location_id,well_id,id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/activities.csv", "r") as f: - qry = 'COPY "ActivityTypeLU"(id,name,description,permission) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open( - "../PVACDdb_migration/csv_data/testing/devdata_wellMeasurement.csv", "r" -) as f: - qry = 'COPY "WellMeasurements"(timestamp,value,well_id,observed_property_id,submitting_user_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/parttypeLU.csv", "r") as f: - qry = 'COPY "PartTypeLU"(id,name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/parts.csv", "r") as f: - qry = 'COPY "Parts"(id,part_number,part_type_id,description,count,note) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/partsassociated.csv", "r") as f: - qry = 'COPY "PartAssociation"(meter_type_id,part_id,commonly_used) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -# Only load the following for local testing -testing = False -if testing: - with open("api/data/testdata_users.csv", "r") as f: - qry = 'COPY "Users"(id, username, full_name, email, hashed_password, disabled, user_role_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - - # with open("api/data/testdata_meterobservations.csv", "r") as f: - # qry = 'COPY "MeterObservations"(timestamp, value, notes, submitting_user_id, meter_id, observed_property_type_id, unit_id, location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - # with open("api/data/testdata_meteractivities.csv", "r") as f: - # qry = 'COPY "MeterActivities"(id, timestamp_start, timestamp_end, notes, submitting_user_id, meter_id, activity_type_id, location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - # with open("api/data/testdata_partsused.csv", "r") as f: - # qry = 'COPY "PartsUsed"(meter_activity_id, part_id, count) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - with open("api/data/devdata_chloridemeasurements.csv", "r") as f: - qry = 'COPY "WellMeasurements"(timestamp,value,well_id,observed_property_id,submitting_user_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -# Create geometries from location lat longs -cursor.execute('update "Locations" set geom = ST_MakePoint(longitude,latitude)') - -conn.commit() -conn.close() - - -# SQL for activity type security scopes if we ever decide to go that route -# INSERT INTO "SecurityScopes" (scope_string, description) -# VALUES -# ('activities:install', 'Submit install activities'), -# ('activities:uninstall', 'Submit install activities'), -# ('activities:general_maintenance', 'Submit general maintenance activities'), -# ('activities:preventative_maintenance', 'Submit preventative maintenance activities'), -# ('activities:repair', 'Submit repair activities'), -# ('activities:rate_meter', 'Submit rate meter activities'), -# ('activities:sell', 'Submit sell activities'), -# ('activities:scrap', 'Submit scrap activities'); - -# INSERT INTO "ScopesRoles" (security_scope_id, user_role_id) -# VALUES -# (7, 2), -# (8, 2), -# (9, 2), -# (10, 2), -# (11, 2), -# (12, 2), -# (13, 2), -# (14, 2), -# (7, 1), -# (8, 1), -# (9, 1), -# (10, 1), -# (11, 1), -# (12, 1); diff --git a/api/main.py b/api/main.py index befc01cd..7c6edd7c 100644 --- a/api/main.py +++ b/api/main.py @@ -9,8 +9,12 @@ from api.routes.activities import activity_router, public_activity_router from api.routes.admin import admin_router from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router -from api.routes.maintenance import maintenance_router +from api.routes.maintenance import ( + authenticated_maintenance_router, + public_maintenance_router, +) from api.routes.meters import authenticated_meter_router, public_meter_router +from api.routes.notifications import notifications_router from api.routes.OSE import ose_router from api.routes.parts import part_router from api.routes.settings import settings_router @@ -40,6 +44,7 @@ "name": "WaterLevels", "description": "Groundwater Depth and Chloride Measurement Related Endpoints", }, + {"name": "Notifications", "description": "Notification related endpoints"}, {"name": "OSE", "description": "Endpoints Used by the OSE to Generate Reports"}, {"name": "Admin", "description": "Admin Functionality Related Endpoints"}, {"name": "Login", "description": "User Auth and Token Related Endpoints"}, @@ -116,8 +121,9 @@ def login_for_access_token( authenticated_router.include_router(activity_router) authenticated_router.include_router(admin_router) authenticated_router.include_router(authenticated_chlorides_router) -authenticated_router.include_router(maintenance_router) +authenticated_router.include_router(authenticated_maintenance_router) authenticated_router.include_router(authenticated_meter_router) +authenticated_router.include_router(notifications_router) authenticated_router.include_router(part_router) authenticated_router.include_router(authenticated_well_measurement_router) authenticated_router.include_router(authenticated_well_router) @@ -130,5 +136,6 @@ def login_for_access_token( app.include_router(public_meter_router) app.include_router(public_well_router) app.include_router(public_chlorides_router) +app.include_router(public_maintenance_router) app.include_router(public_well_measurement_router) app.include_router(authenticated_router) diff --git a/api/models/main_models.py b/api/models/main_models.py index 4892cc78..c289ced4 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -9,6 +9,7 @@ Boolean, Table, Numeric, + Date, ) from sqlalchemy.orm import ( relationship, @@ -18,6 +19,7 @@ deferred, ) from geoalchemy2.shape import to_shape +from datetime import date from typing import Optional, List @@ -31,9 +33,6 @@ class Base(DeclarativeBase): __name__: str -# ---------- Parts/Services/Notes ------------ - - class PartTypeLU(Base): """ The types of parts @@ -64,7 +63,7 @@ class Parts(Base): part_number: Mapped[str] = mapped_column(String, unique=True, nullable=False) description: Mapped[Optional[str]] vendor: Mapped[Optional[str]] - count: Mapped[int] = mapped_column(Integer, default=0) + initial_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 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) @@ -80,14 +79,40 @@ class Parts(Base): secondary=PartAssociation ) + parts_used_links: Mapped[list["PartsUsed"]] = relationship( + back_populates="part", + cascade="all, delete-orphan", + ) -# Association table that links parts and the meter activity they were used on -PartsUsed = Table( - "PartsUsed", - Base.metadata, - Column("meter_activity_id", ForeignKey("MeterActivities.id"), nullable=False), - Column("part_id", ForeignKey("Parts.id"), nullable=False), -) + +class PartsUsed(Base): + __tablename__ = "PartsUsed" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + meter_activity_id: Mapped[int] = mapped_column( + ForeignKey("MeterActivities.id"), nullable=False + ) + part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) + + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + part: Mapped["Parts"] = relationship(back_populates="parts_used_links") + meter_activity: Mapped["MeterActivities"] = relationship( + back_populates="parts_used_links" + ) + + +class PartsAdded(Base): + __tablename__ = "PartsAdded" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) + + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + date: Mapped[date] = mapped_column(Date, nullable=False) # default handled by DB + note: Mapped[str | None] = mapped_column(String, nullable=True) + + part: Mapped["Parts"] = relationship class ServiceTypeLU(Base): @@ -134,8 +159,6 @@ class NoteTypeLU(Base): Column("note_type_id", ForeignKey("NoteTypeLU.id"), nullable=False), ) -# --------- Meter Related Tables --------- - class Meters(Base): """ @@ -189,8 +212,6 @@ class MeterTypeLU(Base): description: Mapped[str] = mapped_column(String) in_use: Mapped[bool] = mapped_column(Boolean, nullable=False) - # parts: Mapped[List["Parts"]] = relationship(secondary=PartAssociation) - class MeterStatusLU(Base): """ @@ -233,7 +254,6 @@ class MeterActivities(Base): activity_type: Mapped["ActivityTypeLU"] = relationship() location: Mapped["Locations"] = relationship() - parts_used: Mapped[List["Parts"]] = relationship("Parts", secondary=PartsUsed) services_performed: Mapped[List["ServiceTypeLU"]] = relationship( "ServiceTypeLU", secondary=ServicesPerformed ) @@ -249,6 +269,11 @@ class MeterActivities(Base): "MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete" ) + parts_used_links: Mapped[list["PartsUsed"]] = relationship( + back_populates="meter_activity", + cascade="all, delete-orphan", + ) + class MeterActivityPhotos(Base): __tablename__ = "MeterActivityPhotos" @@ -343,7 +368,6 @@ class Units(Base): description: Mapped[str] = mapped_column(String) -# Association table that links observed property types and their appropriate units PropertyUnits = Table( "PropertyUnits", Base.metadata, @@ -351,8 +375,6 @@ class Units(Base): Column("unit_id", ForeignKey("Units.id"), nullable=False), ) -# ---------- Other Tables --------------- - class Locations(Base): """ @@ -370,7 +392,6 @@ class Locations(Base): quarter: Mapped[int] = mapped_column(Integer) half_quarter: Mapped[int] = mapped_column(Integer) quarter_quarter: Mapped[int] = mapped_column(Integer) - # geom = mapped_column(Geometry("POINT")) # SQLAlchemy/FastAPI has some issue sending this type_id: Mapped[int] = mapped_column( Integer, ForeignKey("LocationTypeLU.id"), nullable=False @@ -428,9 +449,6 @@ class LandOwners(Base): note: Mapped[str] = mapped_column(String) -# ----------- Security Tables --------------- - - class Users(Base): """ All info about a user of the app @@ -454,6 +472,64 @@ class Users(Base): display_name: Mapped[str] = mapped_column(String, nullable=True) redirect_page: Mapped[str] = mapped_column(String, nullable=True, default="/") avatar_img: Mapped[str] = mapped_column(String, nullable=True) + notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="Notifications.user_id", + ) + created_notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", + back_populates="creator", + foreign_keys="Notifications.created_by", + ) + + +class NotificationTypeLU(Base): + __tablename__ = "notification_type_lu" + + name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) + description: Mapped[Optional[str]] = mapped_column(String) + + notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", back_populates="notification_type" + ) + + +class Notifications(Base): + __tablename__ = "notifications" + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="CASCADE", onupdate="CASCADE"), index=True + ) + notification_type_id: Mapped[int] = mapped_column( + Integer, + ForeignKey( + "notification_type_lu.id", ondelete="RESTRICT", onupdate="CASCADE" + ), + index=True, + ) + created_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="SET NULL", onupdate="CASCADE"), index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + message: Mapped[str] = mapped_column(String, nullable=False) + link: Mapped[Optional[str]] = mapped_column(String(500)) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + created_at: Mapped[DateTime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), index=True + ) + read_at: Mapped[Optional[DateTime]] = mapped_column(DateTime) + + user: Mapped["Users"] = relationship( + "Users", back_populates="notifications", foreign_keys=[user_id] + ) + creator: Mapped[Optional["Users"]] = relationship( + "Users", back_populates="created_notifications", foreign_keys=[created_by] + ) + notification_type: Mapped["NotificationTypeLU"] = relationship( + "NotificationTypeLU", back_populates="notifications" + ) # Association table that links roles and their associated scopes @@ -485,9 +561,6 @@ class UserRoles(Base): ) -# ------------ Wells -------------- - - class WellUseLU(Base): """ The type of well @@ -615,9 +688,6 @@ class workOrders(Base): ) ose_request_id: Mapped[int] = mapped_column(Integer, nullable=True) - # Associated Activities - # associated_activities: Mapped[List['MeterActivities']] = relationship("MeterActivities") - meter: Mapped["Meters"] = relationship() status: Mapped["workOrderStatusLU"] = relationship() assigned_user: Mapped["Users"] = relationship() diff --git a/api/route_util.py b/api/route_util.py index f73e5c1c..389c70f2 100644 --- a/api/route_util.py +++ b/api/route_util.py @@ -1,18 +1,3 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== from fastapi import HTTPException from pydantic import BaseModel @@ -53,6 +38,3 @@ def _get(db, table, dbid): raise HTTPException(status_code=404, detail=f"{table}.{dbid} not found") return db_item - - -# ============= EOF ============================================= diff --git a/api/routes/__init__.py b/api/routes/__init__.py index 34d53041..e69de29b 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -1 +0,0 @@ -# =============================================================================== diff --git a/api/routes/activities.py b/api/routes/activities.py index 0ff7d738..becca741 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -1,11 +1,11 @@ from fastapi import Depends, APIRouter, Query, File, UploadFile, Form from fastapi.exceptions import HTTPException from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, undefer from sqlalchemy.exc import IntegrityError -from sqlalchemy import select, text +from sqlalchemy import select, text, or_ from datetime import datetime -from typing import List +from typing import List, Annotated from api import security from api.schemas import meter_schemas from api.models.main_models import ( @@ -622,7 +622,11 @@ def get_activity_types( tags=["Activities"], ) def get_users(db: Session = Depends(get_db)): - return db.scalars(select(Users).where(Users.disabled == False)).all() + return db.scalars( + select(Users) + .options(undefer(Users.user_role_id)) + .where(Users.disabled == False) + ).all() @activity_router.get( @@ -678,11 +682,16 @@ def get_note_types(db: Session = Depends(get_db)): tags=["Work Orders"], ) def get_work_orders( - filter_by_status: list[WorkOrderStatus] = Query(["Open"]), + filter_by_status: Annotated[list[WorkOrderStatus], Query()] = [ + WorkOrderStatus.Open + ], start_date: datetime = Query(datetime.strptime("2024-06-01", "%Y-%m-%d")), + work_order_id: Annotated[list[int] | None, Query()] = None, + assigned_user_id: int | None = None, + q: str | None = None, db: Session = Depends(get_db), ): - query_stmt = ( + stmt = ( select(workOrders) .options( joinedload(workOrders.status), @@ -693,7 +702,26 @@ def get_work_orders( .where(workOrderStatusLU.name.in_(filter_by_status)) .where(workOrders.date_created >= start_date) ) - work_orders = db.scalars(query_stmt).all() + + if work_order_id: + stmt = stmt.where(workOrders.id.in_(work_order_id)) + + if assigned_user_id: + stmt = stmt.where(workOrders.assigned_user_id == assigned_user_id) + + if q: + q_like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + workOrders.title.ilike(q_like), + workOrders.description.ilike(q_like), + workOrders.creator.ilike(q_like), + workOrders.notes.ilike(q_like), + workOrders.meter.has(Meters.serial_number.ilike(q_like)), + ) + ) + + work_orders = db.scalars(stmt).all() # grab activities separately relevant_activities = db.scalars( diff --git a/api/routes/admin.py b/api/routes/admin.py index b40e0763..71bd8703 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -112,6 +112,33 @@ def create_user(user: security_schemas.NewUser, db: Session = Depends(get_db)): return qualified_user +@admin_router.get( + "/users/{id}", + response_model=security_schemas.User, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def get_user_admin(id: int, db: Session = Depends(get_db)): + """ + Admin-specific single user endpoint (includes username/email/role) + """ + user = db.scalars( + select(Users) + .options( + undefer(Users.username), + undefer(Users.user_role_id), + undefer(Users.email), + joinedload(Users.user_role), + ) + .where(Users.id == id) + ).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return user + + @admin_router.get( "/usersadmin", response_model=List[security_schemas.User], diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 2a4b3449..4adff0cd 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -1,4 +1,5 @@ from fastapi import Depends, APIRouter, Query +from sqlalchemy import func from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List @@ -14,6 +15,8 @@ MeterActivities, ActivityTypeLU, Locations, + workOrders, + workOrderStatusLU, ) from api.session import get_db from api.enums import ScopedUser @@ -32,7 +35,8 @@ autoescape=select_autoescape(["html", "xml"]), ) -maintenance_router = APIRouter() +authenticated_maintenance_router = APIRouter() +public_maintenance_router = APIRouter() class MeterSummary(BaseModel): @@ -55,7 +59,50 @@ class MaintenanceSummaryResponse(BaseModel): table_rows: List[MaintenanceRow] -@maintenance_router.get( +class HomeSummaryResponse(BaseModel): + completed_work_orders: int + repairs_processed: int + reinstallations_processed: int + preventative_maintenance_processed: int + + +@public_maintenance_router.get( + "/maintenance/home_summary", + tags=["Maintenance"], + response_model=HomeSummaryResponse, +) +def get_home_summary(db: Session = Depends(get_db)): + completed_work_orders = ( + db.query(func.count(workOrders.id)) + .join(workOrderStatusLU, workOrderStatusLU.id == workOrders.status_id) + .filter(workOrderStatusLU.name == "Closed") + .scalar() + or 0 + ) + + activity_counts = dict( + db.query(ActivityTypeLU.name, func.count(MeterActivities.id)) + .join(MeterActivities, MeterActivities.activity_type_id == ActivityTypeLU.id) + .filter( + ActivityTypeLU.name.in_( + ["Repair", "Re-install", "Preventative Maintenance"] + ) + ) + .group_by(ActivityTypeLU.name) + .all() + ) + + return { + "completed_work_orders": completed_work_orders, + "repairs_processed": activity_counts.get("Repair", 0), + "reinstallations_processed": activity_counts.get("Re-install", 0), + "preventative_maintenance_processed": activity_counts.get( + "Preventative Maintenance", 0 + ), + } + + +@authenticated_maintenance_router.get( "/maintenance", tags=["Maintenance"], response_model=MaintenanceSummaryResponse, @@ -171,7 +218,7 @@ def get_maintenance_summary( } -@maintenance_router.get( +@authenticated_maintenance_router.get( "/maintenance/pdf", tags=["Maintenance"], dependencies=[Depends(ScopedUser.Read)], diff --git a/api/routes/meters.py b/api/routes/meters.py index 9f78258d..889a2b00 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -12,6 +12,7 @@ Meters, LandOwners, MeterActivities, + PartsUsed, Parts, MeterObservations, Locations, @@ -36,6 +37,7 @@ PHOTO_JWT_EXPIRE_SECONDS = 600 # 10 minutes BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") + # Get paginated, sorted list of meters, filtered by a search string if applicable @authenticated_meter_router.get( "/meters", @@ -46,7 +48,7 @@ def get_meters( # offset: int, limit: int - From fastapi_pagination search_string: str = None, - filter_by_status: List[MeterStatus] = Query('Installed'), + filter_by_status: List[MeterStatus] = Query("Installed"), sort_by: MeterSortByField = MeterSortByField.SerialNumber, sort_direction: SortDirection = SortDirection.Ascending, db: Session = Depends(get_db), @@ -64,14 +66,17 @@ def sort_by_field_to_schema_field(name: MeterSortByField): case MeterSortByField.TRSS: return Locations.trss - + # If 'Warehouse' is in the filter, add 'On Hold' to the filter - if MeterStatus.OnHold not in filter_by_status and MeterStatus.Warehouse in filter_by_status: + if ( + MeterStatus.OnHold not in filter_by_status + and MeterStatus.Warehouse in filter_by_status + ): filter_by_status.append(MeterStatus.OnHold) # Convert enums to strings filter_by_status_str = [status.value for status in filter_by_status] - + # Build the query statement based on query params # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( @@ -99,9 +104,7 @@ def sort_by_field_to_schema_field(name: MeterSortByField): if sort_direction != SortDirection.Ascending: query_statement = query_statement.order_by(desc(schema_field_name)) else: - query_statement = query_statement.order_by( - schema_field_name - ) + query_statement = query_statement.order_by(schema_field_name) return paginate(db, query_statement) @@ -217,7 +220,7 @@ def get_meters_locations( if not meter_ids: return [] # Short-circuit if nothing matched - # Query latest PMs for those meters + # Query latest PMs for those meters pm_query = text( """ SELECT MAX(timestamp_start) AS last_pm, meter_id @@ -230,7 +233,7 @@ def get_meters_locations( pm_years = db.execute(pm_query, {"mids": meter_ids}).fetchall() pm_dict = {row.meter_id: row.last_pm for row in pm_years} - # Map to DTOs manually for added performance + # Map to DTOs manually for added performance meter_map_list = [] for row in result: meter_map_list.append( @@ -248,14 +251,13 @@ def get_meters_locations( "longitude": row.longitude, "trss": row.trss, }, - last_pm=pm_dict.get(row.id) + last_pm=pm_dict.get(row.id), ) ) return meter_map_list - def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = None): if not meter_id and not serial_number: raise HTTPException( @@ -264,6 +266,7 @@ def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = return meter_id, serial_number + # Get single, fully qualified meter # Can use either meter_id or serial_number @authenticated_meter_router.get( @@ -278,12 +281,12 @@ def get_meter( # Create the basic query query = select(Meters).options( - joinedload(Meters.meter_type), - joinedload(Meters.well).joinedload(Wells.location), - joinedload(Meters.status), - joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), - joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), - ) + joinedload(Meters.meter_type), + joinedload(Meters.well).joinedload(Wells.location), + joinedload(Meters.status), + joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), + joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), + ) # Filter by either meter by id or serial number if meter_id: @@ -314,13 +317,12 @@ def get_meter_types(db: Session = Depends(get_db)): def get_meter_registers(db: Session = Depends(get_db)): query = select(meterRegisters).options( joinedload(meterRegisters.dial_units), - joinedload(meterRegisters.totalizer_units) + joinedload(meterRegisters.totalizer_units), ) return db.scalars(query).all() - # A route to return status types from the MeterStatusLU table @authenticated_meter_router.get( "/meter_status_types", @@ -468,7 +470,9 @@ class HistoryType(Enum): joinedload(MeterActivities.location), joinedload(MeterActivities.submitting_user), joinedload(MeterActivities.activity_type), - joinedload(MeterActivities.parts_used).joinedload(Parts.part_type), + joinedload(MeterActivities.parts_used_links) + .joinedload(PartsUsed.part) + .joinedload(Parts.part_type), joinedload(MeterActivities.notes), joinedload(MeterActivities.services_performed), ) @@ -496,8 +500,10 @@ class HistoryType(Enum): for activity in activities: activity.location.geom = None # FastAPI errors when returning this - #Find if there is a well associated with the location - activity_well = db.scalars(select(Wells).where(Wells.location_id == activity.location_id)).first() + # Find if there is a well associated with the location + activity_well = db.scalars( + select(Wells).where(Wells.location_id == activity.location_id) + ).first() photos = [ { @@ -526,8 +532,10 @@ class HistoryType(Enum): for observation in observations: observation.location.geom = None - #Find if there is a well associated with the location - observation_well = db.scalars(select(Wells).where(Wells.location_id == observation.location_id)).first() + # Find if there is a well associated with the location + observation_well = db.scalars( + select(Wells).where(Wells.location_id == observation.location_id) + ).first() formattedHistoryItems.append( { @@ -556,7 +564,7 @@ def create_signed_url(blob_path: str) -> str: creds = impersonated_credentials.Credentials( source_credentials=source_creds, target_principal=target_sa, - target_scopes=['https://www.googleapis.com/auth/devstorage.read_only'], + target_scopes=["https://www.googleapis.com/auth/devstorage.read_only"], lifetime=3600, ) diff --git a/api/routes/notifications.py b/api/routes/notifications.py new file mode 100644 index 00000000..0efdad02 --- /dev/null +++ b/api/routes/notifications.py @@ -0,0 +1,198 @@ +from datetime import date, datetime, time + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_pagination import LimitOffsetPage +from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from api.enums import ScopedUser +from api.models.main_models import Notifications, NotificationTypeLU, Users +from api.schemas.notification_schemas import ( + NotificationCreateRequest, + NotificationCreateResult, + Notification, + NotificationReadUpdate, + NotificationType, + NotificationUnreadCount, +) +from api.security import get_current_user +from api.session import get_db + +notifications_router = APIRouter() + + +@notifications_router.get( + "/notifications", + dependencies=[Depends(ScopedUser.Read)], + response_model=LimitOffsetPage[Notification], + tags=["Notifications"], +) +def get_notifications( + q: str | None = None, + is_read: bool | None = None, + notification_type_id: list[int] | None = Query(None), + created_from: date | None = None, + created_to: date | None = None, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + query_statement = ( + select(Notifications) + .options( + joinedload(Notifications.notification_type), + joinedload(Notifications.creator), + ) + .where(Notifications.user_id == user.id) + .order_by(Notifications.created_at.desc(), Notifications.id.desc()) + ) + + if q: + ilike_term = f"%{q.strip()}%" + query_statement = query_statement.where( + Notifications.title.ilike(ilike_term) + | Notifications.message.ilike(ilike_term) + | Notifications.link.ilike(ilike_term) + ) + + if is_read is not None: + query_statement = query_statement.where(Notifications.is_read == is_read) + + if notification_type_id: + query_statement = query_statement.where( + Notifications.notification_type_id.in_(notification_type_id) + ) + + if created_from is not None: + query_statement = query_statement.where( + Notifications.created_at >= datetime.combine(created_from, time.min) + ) + + if created_to is not None: + query_statement = query_statement.where( + Notifications.created_at <= datetime.combine(created_to, time.max) + ) + + return paginate(db, query_statement) + + +@notifications_router.get( + "/notification_types", + dependencies=[Depends(ScopedUser.Read)], + response_model=list[NotificationType], + tags=["Notifications"], +) +def get_notification_types(db: Session = Depends(get_db)): + return db.scalars( + select(NotificationTypeLU).order_by(func.lower(NotificationTypeLU.name)) + ).all() + + +@notifications_router.get( + "/notifications/unread_count", + dependencies=[Depends(ScopedUser.Read)], + response_model=NotificationUnreadCount, + tags=["Notifications"], +) +def get_unread_notification_count( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + unread_count = db.scalar( + select(func.count(Notifications.id)).where( + Notifications.user_id == user.id, Notifications.is_read.is_(False) + ) + ) + + return {"unread_count": unread_count or 0} + + +@notifications_router.post( + "/notifications", + dependencies=[Depends(ScopedUser.Admin)], + response_model=NotificationCreateResult, + tags=["Notifications"], +) +def create_notifications( + payload: NotificationCreateRequest, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + user_ids = set(payload.user_ids) + + if payload.role_ids: + role_user_ids = db.scalars( + select(Users.id).where( + Users.user_role_id.in_(payload.role_ids), Users.disabled.is_(False) + ) + ).all() + user_ids.update(role_user_ids) + + if user_ids: + valid_user_ids = db.scalars( + select(Users.id).where(Users.id.in_(user_ids), Users.disabled.is_(False)) + ).all() + user_ids = set(valid_user_ids) + + if not user_ids: + raise HTTPException( + status_code=400, + detail="At least one active user or role recipient is required", + ) + + notification_type_exists = db.scalar( + select(NotificationTypeLU.id).where( + NotificationTypeLU.id == payload.notification_type_id + ) + ) + if not notification_type_exists: + raise HTTPException(status_code=404, detail="Notification type not found") + + notifications = [ + Notifications( + user_id=user_id, + notification_type_id=payload.notification_type_id, + created_by=user.id, + title=payload.title.strip(), + message=payload.message.strip(), + link=payload.link.strip() if payload.link else None, + ) + for user_id in user_ids + ] + + db.add_all(notifications) + db.commit() + + return {"created_count": len(notifications)} + + +@notifications_router.patch( + "/notifications", + dependencies=[Depends(ScopedUser.Read)], + response_model=Notification, + tags=["Notifications"], +) +def update_notification_read_status( + payload: NotificationReadUpdate, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + notification = db.scalar( + select(Notifications) + .options( + joinedload(Notifications.notification_type), + joinedload(Notifications.creator), + ) + .where(Notifications.id == payload.id, Notifications.user_id == user.id) + ) + + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + notification.is_read = payload.is_read + notification.read_at = datetime.now() if payload.is_read else None + + db.commit() + db.refresh(notification) + + return notification diff --git a/api/routes/parts.py b/api/routes/parts.py index 42ece499..ca771877 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,21 +1,22 @@ from fastapi import Depends, APIRouter, HTTPException, Query -from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select, func +from sqlalchemy.orm import Session, joinedload, selectinload +from sqlalchemy import select, func, literal, union_all from typing import List, Union, Optional -from datetime import datetime, date +from datetime import datetime, date, time from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO from api.models.main_models import ( Parts, PartsUsed, + PartsAdded, PartAssociation, PartTypeLU, Meters, - MeterTypeLU, - meterRegisters, - MeterActivities, -) + MeterTypeLU, + meterRegisters, + MeterActivities, +) from api.schemas import part_schemas from api.session import get_db from api.route_util import _get @@ -26,12 +27,98 @@ TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" -templates = Environment( - loader=FileSystemLoader(TEMPLATES_DIR), - autoescape=select_autoescape(["html", "xml"]) -) - -part_router = APIRouter() +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]), +) + +part_router = APIRouter() + + +def _build_part_history_response(part_id: int, db: Session) -> part_schemas.PartHistoryResponse: + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + added_q = select( + PartsAdded.id.label("ref_id"), + PartsAdded.part_id.label("part_id"), + PartsAdded.date.label("event_date"), + literal("added").label("event_type"), + PartsAdded.note.label("note"), + PartsAdded.count.label("delta"), + literal(None).label("work_order_id"), + ).where(PartsAdded.part_id == part_id) + + used_q = ( + select( + PartsUsed.id.label("ref_id"), + PartsUsed.part_id.label("part_id"), + MeterActivities.timestamp_start.label("event_date"), + literal("used").label("event_type"), + func.nullif(func.trim(MeterActivities.description), "").label("note"), + (-PartsUsed.count).label("delta"), + MeterActivities.work_order_id.label("work_order_id"), + ) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .where(PartsUsed.part_id == part_id) + ) + + events = union_all(added_q, used_q).subquery() + + rows = db.execute( + select( + events.c.ref_id, + events.c.part_id, + events.c.event_date, + events.c.event_type, + events.c.note, + events.c.delta, + events.c.work_order_id, + ).order_by(events.c.event_date.asc(), events.c.ref_id.asc()) + ).all() + + running = int(part.initial_count) + history: list[part_schemas.PartHistoryRow] = [ + part_schemas.PartHistoryRow( + row_id=f"initial-{part_id}", + part_id=part_id, + event_date=datetime.min, + event_type="initial", + ref_id=None, + note="Initial count", + delta=0, + total_after=running, + work_order_id=None, + ) + ] + + for ref_id, pid, event_date, event_type, note, delta, work_order_id in rows: + if not isinstance(event_date, datetime): + event_date = datetime.combine(event_date, time.min) + + running += int(delta) + history.append( + part_schemas.PartHistoryRow( + row_id=f"{event_type}-{ref_id}", + part_id=pid, + event_date=event_date, + event_type=event_type, + ref_id=ref_id, + note=note, + delta=int(delta), + total_after=running, + work_order_id=work_order_id, + ) + ) + + return part_schemas.PartHistoryResponse( + part_id=part.id, + part_number=part.part_number, + initial_count=part.initial_count, + current_count=running, + history=history, + ) @part_router.get( @@ -42,17 +129,50 @@ ) def get_parts( db: Session = Depends(get_db), - in_use: Optional[bool] = Query( - None, - description="Filter by in_use status" - ), + in_use: Optional[bool] = Query(None, description="Filter by in_use status"), ): - stmt = select(Parts).options(joinedload(Parts.part_type)) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + stmt = ( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) + .options(selectinload(Parts.part_type)) + ) if in_use is not None: stmt = stmt.where(Parts.in_use == in_use) - return db.scalars(stmt).all() + rows = db.execute(stmt).all() + + results = [] + for part, curr in rows: + part.current_count = curr + results.append(part) + + return results @part_router.get( @@ -72,19 +192,16 @@ def get_parts_used_summary( 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 + PartsUsed.part_id.label("used_part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("quantity"), ) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) .filter( MeterActivities.timestamp_start >= start_dt, MeterActivities.timestamp_start <= end_dt, - PartsUsed.c.part_id.in_(parts), + PartsUsed.part_id.in_(parts), ) - .group_by(PartsUsed.c.part_id) + .group_by(PartsUsed.part_id) .subquery() ) @@ -94,7 +211,7 @@ def get_parts_used_summary( Parts.part_number, Parts.description, Parts.price, - func.coalesce(usage_subq.c.quantity, 0).label("quantity") + 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)) @@ -103,17 +220,19 @@ def get_parts_used_summary( results = [] for row in query.all(): - price = row.price or 0 - quantity = row.quantity or 0 + price = float(row.price or 0) + quantity = int(row.quantity or 0) total = price * quantity - results.append({ - "id": row.id, - "part_number": row.part_number, - "description": row.description, - "price": price, - "quantity": quantity, - "total": total, - }) + results.append( + { + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": quantity, + "total": total, + } + ) return results @@ -130,7 +249,9 @@ def download_parts_used_pdf( db: Session = Depends(get_db), ): # Re-use your existing logic - results = get_parts_used_summary(from_date=from_date, to_date=to_date, parts=parts, db=db) + results = get_parts_used_summary( + from_date=from_date, to_date=to_date, parts=parts, db=db + ) # Add running total just for PDF running_total = 0.0 @@ -151,9 +272,7 @@ def download_parts_used_pdf( return StreamingResponse( pdf_io, media_type="application/pdf", - headers={ - "Content-Disposition": "attachment; filename=parts_used_report.pdf" - }, + headers={"Content-Disposition": "attachment; filename=parts_used_report.pdf"}, ) @@ -174,33 +293,67 @@ def get_part_types(db: Session = Depends(get_db)): tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - selected_part = db.scalars( - select(Parts) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + row = db.execute( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) .where(Parts.id == part_id) .options( - joinedload(Parts.part_type), - joinedload(Parts.meter_types), + selectinload(Parts.part_type), + selectinload(Parts.meter_types), ) ).first() + if not row: + return None + + selected_part, curr = row + selected_part.current_count = curr + # 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 - ) + select(meterRegisters).where(meterRegisters.part_id == selected_part.id) ).first() - register_details = part_schemas.Register.register_details.model_validate(register_details) + register_details_obj = None + if register_details is not None: + register_details_obj = ( + 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 - ) + register_settings=register_details_obj, + ) return returned_part @@ -214,8 +367,9 @@ def get_part(part_id: int, db: Session = Depends(get_db)): 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"]: + if k in ["part_type", "meter_types", "current_count"]: continue try: setattr(part_db, k, v) @@ -262,7 +416,7 @@ def create_part(new_part: part_schemas.Part, db: Session = Depends(get_db)): part_type_id=new_part.part_type_id, description=new_part.description, vendor=new_part.vendor, - count=new_part.count, + initial_count=new_part.initial_count, note=new_part.note, in_use=new_part.in_use, commonly_used=new_part.commonly_used, @@ -316,3 +470,159 @@ def get_meter_parts(meter_id: int, db: Session = Depends(get_db)): ).all() return meter_parts + + +@part_router.post( + "/parts/add", + response_model=part_schemas.Part, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def add_parts(payload: part_schemas.PartsAddRequest, db: Session = Depends(get_db)): + # Ensure part exists + part = db.scalars(select(Parts).where(Parts.id == payload.part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + # Insert PartsAdded row (do NOT mutate Parts.initial_count) + added = PartsAdded( + part_id=payload.part_id, + count=payload.count, + date=payload.date, + note=payload.note, + ) + db.add(added) + db.commit() + + # Return updated part with current_count computed (same formula) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + row = db.execute( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) + .where(Parts.id == payload.part_id) + .options(selectinload(Parts.part_type), selectinload(Parts.meter_types)) + ).first() + + if not row: + raise HTTPException(status_code=404, detail="Part not found") + + part_obj, curr = row + part_obj.current_count = curr + return part_obj + + +@part_router.get( + "/parts/{part_id}/history", + response_model=part_schemas.PartHistoryResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def get_part_history(part_id: int, db: Session = Depends(get_db)): + return _build_part_history_response(part_id, db) + + +@part_router.patch( + "/parts/{part_id}/history", + response_model=part_schemas.PartHistoryResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def patch_part_history( + part_id: int, + payload: part_schemas.PartHistoryUpdateRequest, + db: Session = Depends(get_db), +): + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + for row in payload.rows: + normalized_note = row.note.strip() if row.note else None + if normalized_note == "": + normalized_note = None + + if row.event_type == "added": + if row.delta <= 0: + raise HTTPException( + status_code=422, + detail="Added parts rows must have a positive change.", + ) + + added_row = db.scalars( + select(PartsAdded).where( + PartsAdded.id == row.ref_id, + PartsAdded.part_id == part_id, + ) + ).first() + if not added_row: + raise HTTPException(status_code=404, detail="Parts added row not found.") + + added_row.count = row.delta + added_row.date = row.event_date.date() + added_row.note = normalized_note + continue + + if row.delta >= 0: + raise HTTPException( + status_code=422, + detail="Work order rows must have a negative change.", + ) + + parts_used_row = db.scalars( + select(PartsUsed).where( + PartsUsed.id == row.ref_id, + PartsUsed.part_id == part_id, + ) + ).first() + if not parts_used_row: + raise HTTPException(status_code=404, detail="Parts used row not found.") + + activity = db.scalars( + select(MeterActivities).where( + MeterActivities.id == parts_used_row.meter_activity_id + ) + ).first() + if not activity: + raise HTTPException( + status_code=404, + detail="Meter activity for parts used row not found.", + ) + + original_start = activity.timestamp_start + original_end = activity.timestamp_end + duration = original_end - original_start if original_end and original_start else None + + parts_used_row.count = abs(row.delta) + activity.timestamp_start = row.event_date + activity.description = normalized_note + if duration is not None: + activity.timestamp_end = row.event_date + duration + else: + activity.timestamp_end = row.event_date + + db.commit() + return _build_part_history_response(part_id, db) diff --git a/api/routes/settings.py b/api/routes/settings.py index 4e94ae38..90c51e15 100644 --- a/api/routes/settings.py +++ b/api/routes/settings.py @@ -1,12 +1,25 @@ -from fastapi import Depends, APIRouter, HTTPException +from base64 import b64encode +from io import BytesIO + +from fastapi import Depends, APIRouter, HTTPException, File, UploadFile +from PIL import Image, UnidentifiedImageError +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from api.schemas.base import ORMBase from api.session import get_db -from api.security import get_current_user +from api.security import get_current_user, get_password_hash, verify_password from api.models.main_models import Users settings_router = APIRouter() +MAX_AVATAR_FILE_SIZE_BYTES = 5 * 1024 * 1024 +MAX_AVATAR_PIXELS = 4096 * 4096 +ALLOWED_AVATAR_FORMATS = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "WEBP": "image/webp", + "GIF": "image/gif", +} @settings_router.get( @@ -52,6 +65,11 @@ class DisplayNameUpdate(ORMBase): display_name: str +class PasswordResetRequest(ORMBase): + current_password: str + new_password: str + + @settings_router.post( "/settings/display_name", tags=["settings"], @@ -70,3 +88,128 @@ def post_redirect_page( db.refresh(db_user) return {"message": "Display name updated", "display_name": db_user.display_name} + + +@settings_router.post( + "/settings/password_reset", + tags=["settings"], +) +def post_password_reset( + update: PasswordResetRequest, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + if not verify_password(update.current_password, db_user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + if update.current_password == update.new_password: + raise HTTPException( + status_code=400, + detail="New password must be different from current password", + ) + + if len(update.new_password) < 8: + raise HTTPException( + status_code=400, + detail="New password must be at least 8 characters long", + ) + + db_user.hashed_password = get_password_hash(update.new_password) + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update password") + + return {"message": "Password updated"} + + +@settings_router.post( + "/settings/avatar", + tags=["settings"], +) +async def post_avatar( + avatar: UploadFile = File(...), + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + avatar_bytes = await avatar.read() + + if not avatar_bytes: + raise HTTPException(status_code=400, detail="Avatar image is required") + + if len(avatar_bytes) > MAX_AVATAR_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"Avatar image exceeds {MAX_AVATAR_FILE_SIZE_BYTES // (1024 * 1024)} MB limit", + ) + + try: + with Image.open(BytesIO(avatar_bytes)) as image: + width, height = image.size + if width * height > MAX_AVATAR_PIXELS: + raise HTTPException(status_code=400, detail="Avatar image is too large") + + image.verify() + + with Image.open(BytesIO(avatar_bytes)) as image: + image_format = image.format + + except HTTPException: + raise + except (UnidentifiedImageError, Image.DecompressionBombError, OSError): + raise HTTPException(status_code=400, detail="Uploaded file is not a valid image") + + if image_format not in ALLOWED_AVATAR_FORMATS: + raise HTTPException( + status_code=400, + detail="Avatar image must be a JPEG, PNG, WEBP, or GIF file", + ) + + db_user.avatar_img = ( + f"data:{ALLOWED_AVATAR_FORMATS[image_format]};base64," + f"{b64encode(avatar_bytes).decode('ascii')}" + ) + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update avatar image") + + return {"message": "Avatar updated", "avatar_img": db_user.avatar_img} + + +@settings_router.delete( + "/settings/avatar", + tags=["settings"], +) +def delete_avatar( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + db_user.avatar_img = None + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to clear avatar image") + + return {"message": "Avatar cleared", "avatar_img": db_user.avatar_img} diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 4513fd1e..3a7704dc 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -1,11 +1,11 @@ -from typing import List, Optional +from typing import List, Optional, Any, Dict from datetime import datetime, date import re from fastapi import Depends, APIRouter, Query, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from weasyprint import HTML from io import BytesIO @@ -336,6 +336,43 @@ def add_year_average(year: int, label: str): return response_data +@public_well_measurement_router.get( + "/waterlevels/report-averages", + tags=["WaterLevels"], +) +def read_waterlevel_report_averages( + well_ids: List[int] = Query(..., description="One or more well IDs"), + from_date: Optional[date] = Query( + None, description="Start date in ISO format, 'YYYY-MM-DD' (optional)" + ), + to_date: Optional[date] = Query( + None, description="End date in ISO format, 'YYYY-MM-DD' (optional)" + ), + db: Session = Depends(get_db), +): + """ + Report aggregates: + - per-well average depth-to-water for the derived bucket (month or year) + - all-wells average depth-to-water for the derived bucket (month or year) + + Bucket is derived from range: + >= 365 days => year buckets + else => month buckets + """ + + if from_date is None and to_date is None: + raise HTTPException( + status_code=400, detail="from_date and/or to_date is required for reports" + ) + + return get_waterlevel_report_averages( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + db=db, + ) + + @authenticated_well_measurement_router.get( "/waterlevels/pdf", dependencies=[Depends(ScopedUser.Read)], @@ -460,6 +497,13 @@ def make_line_chart(data: dict, title: str): "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" ) + averages = get_waterlevel_report_averages( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + db=db, + ) + html = templates.get_template("waterlevels_report.html").render( from_date=from_date, to_date=to_date, @@ -467,6 +511,7 @@ def make_line_chart(data: dict, title: str): rows=rows, report_title=report_title, report_subtext=report_subtext, + averages=averages, ) pdf_io = BytesIO() @@ -521,3 +566,104 @@ def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): db.commit() return True + + +def get_waterlevel_report_averages( + *, + well_ids: List[int], + from_date: Optional[date], + to_date: Optional[date], + db: Session, +) -> Dict[str, Any]: + """ + Shared logic used by both JSON endpoint and PDF endpoint. + Returns: + { + "bucket": "month" | "year", + "per_well": [ { well_id, ra_number, period_start, avg_value }, ...], + "all_wells": [ { period_start, avg_value }, ...], + } + """ + DEPTH_TO_WATER_NAME = "Depth to water" + + if not well_ids: + return {"bucket": None, "per_well": [], "all_wells": []} + + if from_date is None and to_date is None: + # Let callers decide whether to raise; for PDF we always have both. + return {"bucket": None, "per_well": [], "all_wells": []} + + start_dt = datetime.combine(from_date, datetime.min.time()) if from_date else None + end_dt = datetime.combine(to_date, datetime.max.time()) if to_date else None + + if from_date and to_date: + delta_days = (to_date - from_date).days + bucket_unit = "year" if delta_days >= 365 else "month" + else: + bucket_unit = "month" + + bucket = func.date_trunc(bucket_unit, WellMeasurements.timestamp).label( + "period_start" + ) + + base_filters = [ + ObservedPropertyTypeLU.name == DEPTH_TO_WATER_NAME, + WellMeasurements.well_id.in_(well_ids), + ] + if start_dt: + base_filters.append(WellMeasurements.timestamp >= start_dt) + if end_dt: + base_filters.append(WellMeasurements.timestamp <= end_dt) + + per_well_stmt = ( + select( + WellMeasurements.well_id.label("well_id"), + Wells.ra_number.label("ra_number"), + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(WellMeasurements.well_id, Wells.ra_number, bucket) + .order_by(Wells.ra_number, bucket) + ) + per_well_rows = db.execute(per_well_stmt).all() + + all_wells_stmt = ( + select( + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(bucket) + .order_by(bucket) + ) + all_wells_rows = db.execute(all_wells_stmt).all() + + return { + "bucket": bucket_unit, + "per_well": [ + { + "well_id": r.well_id, + "ra_number": r.ra_number, + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in per_well_rows + ], + "all_wells": [ + { + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in all_wells_rows + ], + } diff --git a/api/routes/wells.py b/api/routes/wells.py index 91dfe8c3..47613e56 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -55,6 +55,28 @@ def get_well_status_types( return db.scalars(select(WellStatus)).all() +@public_well_router.get( + "/wells/{well_id}", + response_model=well_schemas.WellResponse, + tags=["Wells"], +) +def get_well_by_id(well_id: int, db: Session = Depends(get_db)): + stmt = ( + select(Wells) + .options( + joinedload(Wells.location), + joinedload(Wells.use_type), + joinedload(Wells.meters), + joinedload(Wells.well_status), + ) + .where(Wells.id == well_id) + ) + well = db.scalars(stmt).first() + if not well: + raise HTTPException(status_code=404, detail="Well not found") + return well + + @public_well_router.get( "/wells", response_model=LimitOffsetPage[well_schemas.WellResponse], diff --git a/api/schemas/notification_schemas.py b/api/schemas/notification_schemas.py new file mode 100644 index 00000000..c5a35ef0 --- /dev/null +++ b/api/schemas/notification_schemas.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from api.schemas.base import ORMBase +from api.schemas.security_schemas import User + + +class NotificationType(ORMBase): + name: str + description: str | None = None + + +class Notification(ORMBase): + user_id: int + notification_type_id: int + created_by: int | None = None + title: str + message: str + link: str | None = None + is_read: bool + created_at: datetime + read_at: datetime | None = None + notification_type: NotificationType + creator: User | None = None + + +class NotificationUnreadCount(ORMBase): + unread_count: int + + +class NotificationCreateRequest(ORMBase): + role_ids: list[int] = [] + user_ids: list[int] = [] + notification_type_id: int + title: str + message: str + link: str | None = None + + +class NotificationCreateResult(ORMBase): + created_count: int + + +class NotificationReadUpdate(ORMBase): + id: int + is_read: bool diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 1f147c1a..deca5789 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -1,3 +1,5 @@ +from typing import List, Literal, Optional +from datetime import date, datetime from api.schemas.base import ORMBase from api.schemas.meter_schemas import MeterTypeLU @@ -11,7 +13,10 @@ class Part(ORMBase): part_number: str description: str | None = None vendor: str | None = None - count: int + + initial_count: int + current_count: Optional[int] = None + note: str | None = None in_use: bool commonly_used: bool @@ -21,12 +26,14 @@ class Part(ORMBase): 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 @@ -36,9 +43,48 @@ class register_details(ORMBase): number_of_digits: int | None = None multiplier: float | None = None - register_settings: register_details + register_settings: register_details | None = None class PartUsed(ORMBase): part_id: int meter_id: int + + +class PartsAddRequest(ORMBase): + part_id: int + count: int + date: date + note: Optional[str] = None + + +class PartHistoryRow(ORMBase): + row_id: str + part_id: int + event_date: datetime + event_type: Literal["initial", "added", "used"] + ref_id: int | None = None + work_order_id: int | None = None + note: str | None = None + delta: int + total_after: int + + +class PartHistoryUpdateRow(ORMBase): + ref_id: int + event_date: datetime + event_type: Literal["added", "used"] + note: str | None = None + delta: int + + +class PartHistoryUpdateRequest(ORMBase): + rows: List[PartHistoryUpdateRow] + + +class PartHistoryResponse(ORMBase): + part_id: int + part_number: str + initial_count: int + current_count: int + history: List[PartHistoryRow] diff --git a/api/security.py b/api/security.py index cf45b50a..ddb0fb2d 100644 --- a/api/security.py +++ b/api/security.py @@ -6,6 +6,7 @@ from jose import jwt, ExpiredSignatureError from passlib.context import CryptContext from starlette import status +from sqlalchemy import or_ from sqlalchemy.orm import joinedload, undefer, Session from sqlalchemy.sql import select @@ -40,8 +41,8 @@ # Return the current user if credentials were correct, False if not -def authenticate_user(username: str, password: str, db: Session): - user = get_user(username, db) +def authenticate_user(login_identifier: str, password: str, db: Session): + user = get_user_by_login(login_identifier, db) if not user: return False if not verify_password(password, user.hashed_password): @@ -72,9 +73,8 @@ def get_password_hash(password): return pwd_context.hash(password) -def get_user(username: str, db: Session) -> Users: - # Load User with all security scopes - user_stmt = ( +def get_user_query(): + return ( select(Users) .options( undefer(Users.hashed_password), @@ -83,8 +83,22 @@ def get_user(username: str, db: Session) -> Users: undefer(Users.email), joinedload(Users.user_role).joinedload(UserRoles.security_scopes), ) - .filter(Users.username == username) ) + + +def get_user_by_login(login_identifier: str, db: Session) -> Users: + # Allow login via either username or email. + user_stmt = get_user_query().filter( + or_(Users.username == login_identifier, Users.email == login_identifier) + ) + dbuser = db.scalars(user_stmt).first() + + if dbuser: + return dbuser + + +def get_user(username: str, db: Session) -> Users: + user_stmt = get_user_query().filter(Users.username == username) dbuser = db.scalars(user_stmt).first() if dbuser: diff --git a/api/session.py b/api/session.py index dbf1df85..fb3c20d8 100644 --- a/api/session.py +++ b/api/session.py @@ -1,18 +1,3 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -21,14 +6,6 @@ SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL engine = create_engine(SQLALCHEMY_DATABASE_URL) -# if you don't want to install postgres or any database, use sqlite, a file system based database, -# uncomment below lines if you would like to use sqlite and comment above 2 lines of SQLALCHEMY_DATABASE_URL AND engine - -# SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" -# engine = create_engine( -# SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -# ) -print(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -38,6 +15,3 @@ def get_db(): yield db finally: db.close() - - -# ============= EOF ============================================= diff --git a/api/templates/waterlevels_report.html b/api/templates/waterlevels_report.html index 3a70d8b0..aef1a88b 100644 --- a/api/templates/waterlevels_report.html +++ b/api/templates/waterlevels_report.html @@ -1,85 +1,138 @@ + + + - .chart { - margin-top: 2em; - text-align: center; - } - - + +

{{ report_title }}

- -

{{ report_title }}

+ {% if report_subtext %} +

+ {{ report_subtext }} +

+ {% endif %} - {% if report_subtext %} -

- {{ report_subtext }} -

- {% endif %} +

+ From: {{ from_date }}    + To: {{ to_date }} +

-

- From: {{ from_date }}    - To: {{ to_date }} -

+ {% if observation_chart %} +
+

Depth of Water over Time

+ +
+ {% endif %} - {% if observation_chart %} -
-

Depth of Water over Time

- -
- {% endif %} +

Water Level Measurements

+ + + + + + + + + + {% for row in rows %} + + + + + + {% endfor %} + +
WellDate / TimeDepth to Water (ft)
{{ row.well_ra_number }}{{ row.timestamp }}{{ "%.2f"|format(row.depth_to_water) }}
-

Water Level Measurements

- - - - - - - - - - {% for row in rows %} - - - - - - {% endfor %} - -
Date / TimeDepth to Water (ft)Well
{{ row.timestamp }}{{ row.depth_to_water }}{{ row.well_ra_number }}
- + {% if averages and averages.bucket %} +

Report Averages ({{ averages.bucket | title }})

- \ No newline at end of file +

Per-well averages

+ + + + + + + + + + {% for row in averages.per_well %} + + + + + + {% endfor %} + +
WellPeriodAverage Depth to Water (ft)
{{ row.ra_number }}{{ row.period_start }} + {{ "%.2f"|format(row.avg_value) if row.avg_value is not none else "" + }} +
+ +

All-wells average

+ + + + + + + + + {% for row in averages.all_wells %} + + + + + {% endfor %} + +
PeriodAverage Depth to Water (ft)
{{ row.period_start }} + {{ "%.2f"|format(row.avg_value) if row.avg_value is not none else "" + }} +
+ {% endif %} + + diff --git a/api/tests/__init__.py b/api/tests/__init__.py deleted file mode 100644 index a6c2e2d1..00000000 --- a/api/tests/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== - -# ============= EOF ============================================= diff --git a/api/tests/test_main.py b/api/tests/test_main.py deleted file mode 100644 index 1b3a1733..00000000 --- a/api/tests/test_main.py +++ /dev/null @@ -1,263 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -import datetime -import os - -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.event import listen -from sqlite3 import OperationalError - -from api.dbsetup import setup_db -from api.main import app, get_db -from api.mdels.main_models import Base -from api.routes.alerts import write_user -from api.routes.reports import report_user -from api.security import get_current_user -from api.models.security_models import User - -SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" - - -def load_spatialite(dbapi_conn, connection_record): - dbapi_conn.enable_load_extension(True) - try: - dbapi_conn.load_extension("/usr/lib/x86_64-linux-gnu/mod_spatialite.so") - except OperationalError: - dbapi_conn.load_extension("/usr/lib/aarch64-linux-gnu/mod_spatialite.so") - - -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) - -listen(engine, "connect", load_spatialite) - -Base.metadata.create_all(bind=engine) -TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - -def override_get_db(): - try: - db = TestingSessionLocal() - yield db - finally: - db.close() - - -os.environ["POPULATE_DB"] = "true" -setup_db(engine, next(override_get_db())) - - -def override_user(): - return User(disabled=False) - - -app.dependency_overrides[get_db] = override_get_db -app.dependency_overrides[get_current_user] = override_user -client = TestClient(app) - - -def test_read_repair_report(): - response = client.get("/repair_report") - assert response.status_code == 200 - data = response.json() - assert len(data) == 4 - assert data[0]["meter_serial_number"] == "1992-4-1234" - assert data[0]["e_read"] == "E 2412341" - assert data[0]["h2o_read"] == 638.831 - - -def test_read_meters(): - response = client.get("/meters") - assert response.status_code == 200 - data = response.json() - assert data[0]["serial_number"] == "1992-4-1234" - assert data[0]["name"] == "moo" - assert data[1]["name"] == "tor" - assert data[2]["name"] == "hag" - - -def test_patch_alert(): - response = client.patch("/alerts/1", json={"alert": "patched alert"}) - assert response.status_code == 200 - - -def test_read_alerts(): - response = client.get("/alerts") - assert response.status_code == 200 - assert response.json()[0]["alert"] == "patched alert" - assert response.json()[0]["meter_serial_number"] == "1992-4-1234" - assert "open_timestamp" in response.json()[0].keys() - assert response.json()[0]["closed_timestamp"] is None - assert response.json()[0]["active"] - - -def test_patch_alert_closed(): - response = client.patch( - "/alerts/1", json={"closed_timestamp": datetime.datetime.now().isoformat()} - ) - assert response.status_code == 200 - - -def test_read_wells(): - response = client.get("/wells") - assert response.status_code == 200 - assert sorted(response.json()[0].keys()) == [ - "id", - "latitude", - "location", - "longitude", - "name", - "osepod", - "owner_id", - ] - - -# -# -def test_post_meter(): - response = client.post( - "/meters", - json={ - "id": 10, - "name": "foo", - "serial_id": 1234, - "serial_case_diameter": 4, - "serial_year": 1990, - }, - ) - assert response.status_code == 200 - response = client.get("/meters") - assert response.status_code == 200 - assert len(response.json()) == 4 - - -def test_post_alert(): - response = client.post("/alerts", json={"meter_id": 1, "alert": "this is an alert"}) - assert response.status_code == 200 - response = client.get("/alerts") - assert response.status_code == 200 - assert len(response.json()) == 2 - - -def test_read_alert(): - response = client.get("/alerts/1") - assert response.status_code == 200 - - -def test_api_status(): - response = client.get("/api_status") - assert response.status_code == 200 - assert response.json() == {"ok": True} - - -def test_meter_status_lu(): - response = client.get("/meter_status_lu") - assert response.status_code == 200 - - data = response.json() - assert len(data) == 3 - assert data[0]["name"] == "POK" - assert data[0]["description"] == "Pump OK" - - -def test_wellconstruction(): - response = client.get("/wellconstruction/1") - assert response.status_code == 200 - data = response.json() - assert data["id"] == 1 - assert data["casing_diameter"] == 0 - assert data["hole_depth"] == 0 - assert data["well_depth"] == 0 - assert data["screens"] == [{"id": 1, "top": 10, "bottom": 20}] - - -def test_waterlevels(): - response = client.get("/waterlevels") - assert response.status_code == 200 - - -def test_well_waterlevels(): - response = client.get("/waterlevels?well_id=1") - assert response.status_code == 200 - assert len(response.json()) == 1 - - response = client.get("/waterlevels?well_id=0") - assert response.status_code == 200 - assert len(response.json()) == 0 - - -def test_well_chlorides(): - response = client.get("/chlorides?well_id=1") - assert response.status_code == 200 - assert len(response.json()) == 1 - assert response.json()[0]["value"] == 1234.0 - - response = client.get("/chlorides?well_id=0") - assert response.status_code == 200 - assert len(response.json()) == 0 - - -def test_fuzzy_meter_search(): - response = client.get("/meters?fuzzy_serial=1990") - assert response.status_code == 200 - assert len(response.json()) == 1 - - response = client.get("/meters?fuzzy_owner_name=spen") - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]["name"] == "tor" - - -def test_fuzzy_well_osepod_search(): - response = client.get("/wells?osepod=1237") - assert response.status_code == 200 - assert len(response.json()) == 1 - - -def test_wells_by_plss(): - response = client.get("/wells?township=100") - assert response.status_code == 200 - assert len(response.json()) == 3 - - response = client.get("/wells?township=100&range_=10") - assert response.status_code == 200 - assert len(response.json()) == 3 - - response = client.get("/wells?township=100&range_=10§ion=4") - assert response.status_code == 200 - assert len(response.json()) == 3 - - response = client.get("/wells?township=100&range_=10§ion=4&quarter=2") - assert response.status_code == 200 - assert len(response.json()) == 1 - - response = client.get("/wells?township=100&half_quarter=1") - assert response.status_code == 200 - assert len(response.json()) == 1 - - -# spatial queries not compatible with spatialite -# def test_read_wells_spatial(): -# response = client.get('/wells?radius=50&latlng=35.4,-105.2') -# assert response.status_code == 200 -# data = response.json() -# assert len(data) == 1 -# ============= EOF ============================================= diff --git a/api/winenv.bat b/api/winenv.bat deleted file mode 100644 index 1a5349c9..00000000 --- a/api/winenv.bat +++ /dev/null @@ -1,11 +0,0 @@ -:: A batch file to quickly set environmental variables -@echo off -set POSTGRES_USER=docker -set POSTGRES_PASSWORD=docker -set POSTGRES_SERVER=db -set POSTGRES_PORT=5432 -set POSTGRES_DB=gis - -:: Uncomment these to initially populate database -set SETUP_DB=1 -set POPULATE_DB=1 \ No newline at end of file diff --git a/api/winenv.ps1 b/api/winenv.ps1 deleted file mode 100644 index 84bbf909..00000000 --- a/api/winenv.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$Env:POSTGRES_USER='docker' -$Env:POSTGRES_PASSWORD='docker' -$Env:POSTGRES_SERVER='db' -$Env:POSTGRES_PORT='5432' -$Env:POSTGRES_DB='gis' - -$Env:SETUP_DB='1' -$Env:POPULATE_DB='1' diff --git a/api/xls_persistence.py b/api/xls_persistence.py deleted file mode 100644 index e4b0c09e..00000000 --- a/api/xls_persistence.py +++ /dev/null @@ -1,47 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -import xlsxwriter -from geoalchemy2.elements import WKBElement - - -def populate_sheet(sh, records, columns): - for col, attr in enumerate(columns): - sh.write(0, col, attr.name) - # - for row, record in enumerate(records): - for col, attr in enumerate(columns): - try: - value = getattr(record, attr.name) - sh.write(row + 1, col, value) - except BaseException: - sh.write(row + 1, col, "") - - -def make_xls_backup(db, tables): - path = "backup.xlsx" - wb = xlsxwriter.Workbook(path) - - for table in tables: - records = db.query(table).all() - sh = wb.add_worksheet(table.__tablename__) - populate_sheet(sh, records, table.__table__.columns) - - wb.close() - - return path - - -# ============= EOF ============================================= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f7d7aea..a05b7ff4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", + "@tanstack/react-router": "^1.99.7", "dayjs": "^1.11.9", "immer": "^10.0.2", "js-yaml": "^4.1.1", @@ -34,14 +35,13 @@ "react-number-format": "^5.3.1", "react-plotly.js": "^2.6.0", "react-query": "^3.39.3", - "react-router": "^6.30.3", - "react-router-dom": "^6.30.3", "serve": "^14.0.1", "use-debounce": "^9.0.4", "yup": "^1.2.0" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@tanstack/router-plugin": "^1.99.7", "@types/geojson": "^7946.0.14", "@types/jest": "^29.5.2", "@types/leaflet": "^1.9.16", @@ -62,73 +62,199 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -137,64 +263,87 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, "engines": { - "node": ">=4" + "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -230,10 +379,10 @@ } }, "node_modules/@dicebear/adventurer": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.4.tgz", - "integrity": "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.0.tgz", + "integrity": "sha512-VfTOSc6XRdRGjdkTSC7AHmV1HdGlmUQ4/6TCb570uLsPFyFkG7nCVQYjbWZun3BilIQsyIuLSSWxrZWR+XH/rg==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -242,10 +391,10 @@ } }, "node_modules/@dicebear/adventurer-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.4.tgz", - "integrity": "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.4.0.tgz", + "integrity": "sha512-zlpEF4KJhfl96j0M6wPmgaUVz20VKYZziIcIvf9pqGrvsTl1kDnoBtpmAROuU3e7FeCqDhk4qSQvorusW+L62g==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -254,10 +403,10 @@ } }, "node_modules/@dicebear/avataaars": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.4.tgz", - "integrity": "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.0.tgz", + "integrity": "sha512-zqpXcl+RHza3DeN3WcqtXMkQanI6wHUg/plJFb+uqI4KeXkJ6NBVsHNH7A4EImY/XZ4H3nw1g30io//ji5bxkw==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -266,10 +415,10 @@ } }, "node_modules/@dicebear/avataaars-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.4.tgz", - "integrity": "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.4.0.tgz", + "integrity": "sha512-tGtmnBfjgdElgKouzEuIdJXQ0makePI1rZnVLW5hJxA6A3xWEAQOIHCqTA0UDBHjM/uJP5lspxUIJrJHU76/8Q==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -278,10 +427,10 @@ } }, "node_modules/@dicebear/big-ears": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.4.tgz", - "integrity": "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.0.tgz", + "integrity": "sha512-d43CWzswbwed4q1RZFxt1qlhQfqzPGZVwGe0/+PZIr1B4U8y3/AqT7y1TptTdk6lL65XNhJKM30cxn72+x5fTA==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -290,10 +439,10 @@ } }, "node_modules/@dicebear/big-ears-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.4.tgz", - "integrity": "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.4.0.tgz", + "integrity": "sha512-xUJGFriKkBEs4dRe8rZ7fqT49x0JgOVwpl1A5hYXYI6BPZqyX4wfCPPynyPtYyZDWy+nuCWxFgc2fZCBV/hW7g==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -302,10 +451,10 @@ } }, "node_modules/@dicebear/big-smile": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.4.tgz", - "integrity": "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.0.tgz", + "integrity": "sha512-LPXCc11Yw/p54OYNjyyiNoCdqXybuAWJRxkcpThx9S/TKouuwnEroj5PL3b1+unreCHtMDzkcO9dia7mqX9DYQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -314,10 +463,10 @@ } }, "node_modules/@dicebear/bottts": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.4.tgz", - "integrity": "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.0.tgz", + "integrity": "sha512-vuFC5HRfzla7YH2s02CBrxBr+ninbZu9PtO3a72JoO8Da02/POI7RF3WjjlzfRG4+i5NHyn77gKsl2cy8rTTXA==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -326,10 +475,10 @@ } }, "node_modules/@dicebear/bottts-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.4.tgz", - "integrity": "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.4.0.tgz", + "integrity": "sha512-ACIM6Cu0es4TdMA0jHUlKtWh50AZS0HJ5ykeBueZpPhMMGbjkRV90Sit/4+I2ghTOZ6Veug+UjEKz4VUbkfKwA==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -338,41 +487,42 @@ } }, "node_modules/@dicebear/collection": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.4.tgz", - "integrity": "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA==", - "license": "MIT", - "dependencies": { - "@dicebear/adventurer": "9.2.4", - "@dicebear/adventurer-neutral": "9.2.4", - "@dicebear/avataaars": "9.2.4", - "@dicebear/avataaars-neutral": "9.2.4", - "@dicebear/big-ears": "9.2.4", - "@dicebear/big-ears-neutral": "9.2.4", - "@dicebear/big-smile": "9.2.4", - "@dicebear/bottts": "9.2.4", - "@dicebear/bottts-neutral": "9.2.4", - "@dicebear/croodles": "9.2.4", - "@dicebear/croodles-neutral": "9.2.4", - "@dicebear/dylan": "9.2.4", - "@dicebear/fun-emoji": "9.2.4", - "@dicebear/glass": "9.2.4", - "@dicebear/icons": "9.2.4", - "@dicebear/identicon": "9.2.4", - "@dicebear/initials": "9.2.4", - "@dicebear/lorelei": "9.2.4", - "@dicebear/lorelei-neutral": "9.2.4", - "@dicebear/micah": "9.2.4", - "@dicebear/miniavs": "9.2.4", - "@dicebear/notionists": "9.2.4", - "@dicebear/notionists-neutral": "9.2.4", - "@dicebear/open-peeps": "9.2.4", - "@dicebear/personas": "9.2.4", - "@dicebear/pixel-art": "9.2.4", - "@dicebear/pixel-art-neutral": "9.2.4", - "@dicebear/rings": "9.2.4", - "@dicebear/shapes": "9.2.4", - "@dicebear/thumbs": "9.2.4" + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.4.0.tgz", + "integrity": "sha512-OVMKwwS+npvbkJeOSIhtciOemUx//o1TpgwoOwGMffywsalL7+Mz9he/i6kT3xxi4mVkFDR46rtz4J/VlexXnQ==", + "license": "MIT", + "dependencies": { + "@dicebear/adventurer": "9.4.0", + "@dicebear/adventurer-neutral": "9.4.0", + "@dicebear/avataaars": "9.4.0", + "@dicebear/avataaars-neutral": "9.4.0", + "@dicebear/big-ears": "9.4.0", + "@dicebear/big-ears-neutral": "9.4.0", + "@dicebear/big-smile": "9.4.0", + "@dicebear/bottts": "9.4.0", + "@dicebear/bottts-neutral": "9.4.0", + "@dicebear/croodles": "9.4.0", + "@dicebear/croodles-neutral": "9.4.0", + "@dicebear/dylan": "9.4.0", + "@dicebear/fun-emoji": "9.4.0", + "@dicebear/glass": "9.4.0", + "@dicebear/icons": "9.4.0", + "@dicebear/identicon": "9.4.0", + "@dicebear/initials": "9.4.0", + "@dicebear/lorelei": "9.4.0", + "@dicebear/lorelei-neutral": "9.4.0", + "@dicebear/micah": "9.4.0", + "@dicebear/miniavs": "9.4.0", + "@dicebear/notionists": "9.4.0", + "@dicebear/notionists-neutral": "9.4.0", + "@dicebear/open-peeps": "9.4.0", + "@dicebear/personas": "9.4.0", + "@dicebear/pixel-art": "9.4.0", + "@dicebear/pixel-art-neutral": "9.4.0", + "@dicebear/rings": "9.4.0", + "@dicebear/shapes": "9.4.0", + "@dicebear/thumbs": "9.4.0", + "@dicebear/toon-head": "9.4.0" }, "engines": { "node": ">=18.0.0" @@ -382,22 +532,22 @@ } }, "node_modules/@dicebear/core": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz", - "integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.0.tgz", + "integrity": "sha512-uoAG5mPBX+kQTtVerWUoH5e7rezG+DV/vJ5icd/kGooGyylH0nuJIlA6todkKGQv+/b0QNo+EzNF6Nc4UTE3wQ==", "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.11" + "@types/json-schema": "^7.0.15" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@dicebear/croodles": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.4.tgz", - "integrity": "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.0.tgz", + "integrity": "sha512-tC68VGu0XOtDd4aOORvchtRy1EMphuTWCl/vDIlS9zuKJJxIJCh0r7mREn/Azds07Hdg1R1Mr8j85tdVonEpgQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -406,10 +556,10 @@ } }, "node_modules/@dicebear/croodles-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.4.tgz", - "integrity": "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.4.0.tgz", + "integrity": "sha512-kRFE46B+WfGU4yDaD0ESSvt9A6CBtxuR7sGcFJ4YhK4T/O+tnP+iqRuQ3+ob1oNdEW3oQaD9aBioi3hBfbrrBA==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -418,10 +568,10 @@ } }, "node_modules/@dicebear/dylan": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.4.tgz", - "integrity": "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.4.0.tgz", + "integrity": "sha512-1HxZyVmPf5ElERs4NqDtWHw6OBDae5v6t4zspCXRzMH/H0onwlbx3uAZDNGFdPgah8bSV3MhAzhggTCNcWtMxw==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -430,10 +580,10 @@ } }, "node_modules/@dicebear/fun-emoji": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.4.tgz", - "integrity": "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.0.tgz", + "integrity": "sha512-dDOw30RfCNfqqeXny4eQLgyMEXfZ0Y5Gz+rSPCuXGw735rCF+Wehyy4tzl2icCkXhWK9attlAY9anjV45k/2aQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -442,9 +592,9 @@ } }, "node_modules/@dicebear/glass": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.4.tgz", - "integrity": "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.4.0.tgz", + "integrity": "sha512-piYKjXTPiTmdgkEW8OEAQNTbcAwtI0+iR2ODfKWnWBy8lM+rnY4TmBi3RgMFJXLFqjPgu38SXTsd2bWAfVa4MQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -454,9 +604,9 @@ } }, "node_modules/@dicebear/icons": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.4.tgz", - "integrity": "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.4.0.tgz", + "integrity": "sha512-iwA4uM8E9B9kCEMJfxvgfDGje3h2ZE84SDuvJjjCWWZP/LJ5YX50QcRrfknRffD439DXJsKdXy9ku4OB5G7TkQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -466,9 +616,9 @@ } }, "node_modules/@dicebear/identicon": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.4.tgz", - "integrity": "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.0.tgz", + "integrity": "sha512-6X5z7oHeGPuw9i7DaHQAQdHGAu9KYUgTZx8lWLJH/wutzCkygpNm7P0Q1FaP8zmdLkhj4AknQOoZ5AW0kaW4Lg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -478,9 +628,9 @@ } }, "node_modules/@dicebear/initials": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz", - "integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.0.tgz", + "integrity": "sha512-Qt0jDQKyo63HD8o3mXgb+PzM0L01BWpURtrEETZEGgES+C3Qz5fQPbVDdkKSNXn5yyjv6LbdniJJUjTxDmQAQw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -490,9 +640,9 @@ } }, "node_modules/@dicebear/lorelei": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.4.tgz", - "integrity": "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.0.tgz", + "integrity": "sha512-P91tqHckYj+IPw906F3SQwKvIMClJFwfYb4mvJGYoy/PyQVcRdT7ziKbYrG70bHKgdSEQSAarOdLH4EDLX4IpA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -502,9 +652,9 @@ } }, "node_modules/@dicebear/lorelei-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.4.tgz", - "integrity": "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.4.0.tgz", + "integrity": "sha512-3ceiazxgIN/9p6Ndg6X76N+RH61PSg0+717YiAZ5WN/epia/UUYzsZ5RrLyjrdq30SRNeHawp58qbAkOYMWD7g==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -514,10 +664,10 @@ } }, "node_modules/@dicebear/micah": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.4.tgz", - "integrity": "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.0.tgz", + "integrity": "sha512-fMtENHrq7ZFNt+HpZTP0yr06dw76ur6SCjMK1eQBX6fwgtJ8HkHa/4TjhpjvQTarJJPs6FDPtGkHcYKCehBUNw==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -526,10 +676,10 @@ } }, "node_modules/@dicebear/miniavs": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.4.tgz", - "integrity": "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.4.0.tgz", + "integrity": "sha512-Gh4C8xF3vRM+FkEtfiYWLaRYCZP1Bzdg/gjLqvn/rJ9TCo645KksPcpABShZv7BPbOCkr17lhSrfBmlRjQnzkQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -538,9 +688,9 @@ } }, "node_modules/@dicebear/notionists": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.4.tgz", - "integrity": "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.0.tgz", + "integrity": "sha512-MgZuW5of3b3cjLFi+D+iONZ3t/t9TZHYUyBXDmRxgeQW+l6td3n8Mjg8eA81jbzVC2RNyxKCOjZu6EyjyX88tA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -550,9 +700,9 @@ } }, "node_modules/@dicebear/notionists-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.4.tgz", - "integrity": "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.4.0.tgz", + "integrity": "sha512-wzg/NLcIzSM2O8IXcEFucYLJypS7I3VKmBsn4ShdM1qQ5nNlA8Ig3e9GKkfxRS2K+xTNDHyXuXNB88pj5Uzmig==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -562,9 +712,9 @@ } }, "node_modules/@dicebear/open-peeps": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.4.tgz", - "integrity": "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.4.0.tgz", + "integrity": "sha512-IxbfUWoYEUFdqYqz0iLYODbShV3GWx0t2Afq4pw6KTSewusjMIuYlvyK4z8cFkc2Ai/7VXRBLvQd+YA8KRMpIw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -574,10 +724,10 @@ } }, "node_modules/@dicebear/personas": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.4.tgz", - "integrity": "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA==", - "license": "MIT", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.4.0.tgz", + "integrity": "sha512-CjmIiOEwEmQeccIF0U7uzzBLOn9PWNFz87vAAiToWVzA4pVuzHgA+OiKzC6n91lZfRy76bGL1JtR8/ZppCN0YA==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -586,9 +736,9 @@ } }, "node_modules/@dicebear/pixel-art": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.4.tgz", - "integrity": "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.0.tgz", + "integrity": "sha512-oQm9pGOaYCgfnxtzNY8xaJa3ZBH12xd7p4UT35ghvtRgk394uCnmz/bg71tnj2ynwVmZ4s5gBoWlUymnTvvCOw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -598,9 +748,9 @@ } }, "node_modules/@dicebear/pixel-art-neutral": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.4.tgz", - "integrity": "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.4.0.tgz", + "integrity": "sha512-OGYFbow6Hu345OObR0dPOAImuGP5vFqNkzkfkEPF4DPbLnCa3RjpeoCkyB+/Gvz7qAtyRR8W57Tfj6PQVRLLXg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -610,9 +760,9 @@ } }, "node_modules/@dicebear/rings": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.4.tgz", - "integrity": "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.0.tgz", + "integrity": "sha512-lEhPwUd/uZFLAWM296/aNSGaCyT9NaTXm6V3izFtD8pywceze+sV3s46uLKpvCKUEcI4ia5iMERV35EH5P2ixg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -622,9 +772,9 @@ } }, "node_modules/@dicebear/shapes": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.4.tgz", - "integrity": "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.0.tgz", + "integrity": "sha512-WTH1j6xqwdzBYiTPsCECqlB7kYC0TIbdlg49jEZJp9qP0tguVMH+M7GmWY5TO2chTRmYjJREmgvZWPgmE1Sd9Q==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -634,9 +784,9 @@ } }, "node_modules/@dicebear/thumbs": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.4.tgz", - "integrity": "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.0.tgz", + "integrity": "sha512-eppbqo+3CvlDF4cwWNBsdNmtXHkVaj5AvM9KimVBWdp0S98foTTekCaQCBCmDfATywVXEGk+GaThTZdYgIE/0Q==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -645,6 +795,18 @@ "@dicebear/core": "^9.0.0" } }, + "node_modules/@dicebear/toon-head": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/toon-head/-/toon-head-9.4.0.tgz", + "integrity": "sha512-3u4ghFUFhnV1LYAfbltihOnASCk4qeWYLjg8B9U6drovrxY4yfX13vqNzQePormLvehXKpm9+gKbmy4kMt2w+g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -684,9 +846,9 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -742,9 +904,9 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -792,9 +954,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -809,9 +971,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -826,9 +988,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -843,9 +1005,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -860,9 +1022,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -877,9 +1039,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -894,9 +1056,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -911,9 +1073,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -928,9 +1090,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -945,9 +1107,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -962,9 +1124,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -979,9 +1141,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -996,9 +1158,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -1013,9 +1175,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -1030,9 +1192,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -1047,9 +1209,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -1064,9 +1226,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -1081,9 +1243,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -1098,9 +1260,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1115,9 +1277,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -1132,9 +1294,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -1148,10 +1310,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1166,9 +1345,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1183,9 +1362,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1200,9 +1379,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1217,9 +1396,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1249,9 +1428,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1259,34 +1438,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1297,20 +1479,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1334,19 +1516,22 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1354,13 +1539,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1368,31 +1553,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -1400,9 +1585,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@hookform/resolvers": { @@ -1425,33 +1610,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1467,9 +1638,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1525,17 +1696,24 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1547,19 +1725,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "peer": true, "dependencies": { @@ -1568,15 +1737,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1691,7 +1860,7 @@ "version": "5.0.0-dev.20240529-082515-213b5e33ab", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-dev.20240529-082515-213b5e33ab.tgz", "integrity": "sha512-3ic6fc6BHstgM+MGqJEVx3zt9g5THxVXm3VVFUfdeplPqAWWgW2QoKfZDLT10s+pi+MAkpgEBP0kgRidf81Rsw==", - "deprecated": "This package has been replaced by @base-ui-components/react", + "deprecated": "This package has been replaced by @base-ui/react", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.6", @@ -1721,13 +1890,13 @@ } }, "node_modules/@mui/base/node_modules/@mui/utils": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", - "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", + "@mui/types": "~7.2.24", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -1751,9 +1920,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.14.tgz", - "integrity": "sha512-sbjXW+BBSvmzn61XyTMun899E7nGPTXwqD9drm1jBUAvWEhJpPFIRxwQQiATWZnd9rvdxtnhhdsDxEGWI0jxqA==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1761,9 +1930,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.14.tgz", - "integrity": "sha512-heL4S+EawrP61xMXBm59QH6HODsu0gxtZi5JtnXF2r+rghzyU/3Uftlt1ij8rmJh+cFdKTQug1L9KkZB5JgpMQ==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9" @@ -1787,16 +1956,16 @@ } }, "node_modules/@mui/material": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.14.tgz", - "integrity": "sha512-eSXQVCMKU2xc7EcTxe/X/rC9QsV2jUe8eLM3MUCPYbo6V52eCE436akRIvELq/AqZpxx2bwkq7HC0cRhLB+yaw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.16.14", - "@mui/system": "^5.16.14", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.14", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", @@ -1832,13 +2001,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.14.tgz", - "integrity": "sha512-12t7NKzvYi819IO5IapW2BcR33wP/KAVrU8d7gLhGHoAmhDxyXlRoKiRij3TOD8+uzk0B6R9wHUNKi4baJcRNg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.16.14", + "@mui/utils": "^5.17.1", "prop-types": "^15.8.1" }, "engines": { @@ -1859,13 +2028,14 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz", - "integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, @@ -1891,16 +2061,16 @@ } }, "node_modules/@mui/system": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.14.tgz", - "integrity": "sha512-KBxMwCb8mSIABnKvoGbvM33XHyT+sN0BzEBG+rsSc0lLQGzs7127KWkCA6/H8h6LZ00XpBEME5MAj8mZLiQ1tw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.16.14", - "@mui/styled-engine": "^5.16.14", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.14", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -1931,13 +2101,10 @@ } }, "node_modules/@mui/types": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", - "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.3" - }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1948,13 +2115,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.14.tgz", - "integrity": "sha512-wn1QZkRzSmeXD1IguBVvJJHV3s6rxJrfb6YuC9Kk6Noh9f8Fb54nUs5JRkKm+BOerRhj5fLg05Dhx/H3Ofb8Mg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/types": "^7.2.15", + "@mui/types": "~7.2.15", "@types/prop-types": "^15.7.12", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -1978,21 +2145,21 @@ } }, "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==", + "version": "8.27.5", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.27.5.tgz", + "integrity": "sha512-45XAKzEaTXx8D612zAghr6ofNK/OHukKTl9kuI+UmpaOE3se+khNwKHeOyXcus2uUoGoL6jxZcENklZmJDxzCg==", "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", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-charts-vendor": "8.26.0", + "@mui/x-internal-gestures": "0.4.0", + "@mui/x-internals": "8.26.0", "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" + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -2015,96 +2182,65 @@ } }, "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==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.26.0.tgz", + "integrity": "sha512-R//+WSWvsLJRTjTRN90EKX9sgRzAb4HQBvtUA3cTQpkGrmEjmatD4BJAm3IdRdkSagf6yKWF+ypESctyRhbwnA==", "license": "MIT AND ISC", "dependencies": { - "@babel/runtime": "^7.27.0", + "@babel/runtime": "^7.28.4", + "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", - "@types/d3-delaunay": "^6.0.4", + "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.7", "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", "d3-color": "^3.1.0", - "d3-delaunay": "^6.0.4", + "d3-format": "^3.1.0", "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", + "d3-time-format": "^4.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" + "flatqueue": "^3.0.0", + "internmap": "^2.0.3" } }, - "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", + "node_modules/@mui/x-charts/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", "dependencies": { - "d3-path": "^3.1.0" + "@babel/runtime": "^7.28.6" }, - "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" + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, - "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" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "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==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", + "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/types": "^7.4.6", + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.1" + "react-is": "^19.2.3" }, "engines": { "node": ">=14.0.0" @@ -2123,35 +2259,15 @@ } } }, - "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", - "integrity": "sha512-7zbDbFrhV6ODjyn3ImOZG34nbMbCvmHgqYTYP273TNAj8hMy4BiLyiKFFZTzVddIj3KQ6qLzBpByhqifGgEDOg==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.12.tgz", + "integrity": "sha512-MaEC7ubr/je8jVWjdRU7LxBXAzlOZwFEdNdvlDUJIYkRa3TRCQ1HsY8Gd8Od0jnlnMYn9M4BrEfOrq9VRnt4bw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-internals": "7.26.0", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", @@ -2167,8 +2283,8 @@ "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14 || ^6.0.0", - "@mui/system": "^5.15.14 || ^6.0.0", + "@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" }, @@ -2181,19 +2297,39 @@ } } }, - "node_modules/@mui/x-date-pickers": { - "version": "6.20.2", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.20.2.tgz", - "integrity": "sha512-x1jLg8R+WhvkmUETRfX2wC+xJreMii78EXKLl6r3G+ggcAZlPyt0myID1Amf6hvJb9CtR7CgUo8BwR+1Vx9Ggw==", + "node_modules/@mui/x-data-grid/node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.2", - "@mui/base": "^5.0.0-beta.22", - "@mui/utils": "^5.14.16", - "@types/react-transition-group": "^4.4.8", - "clsx": "^2.0.0", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^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-date-pickers": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.20.2.tgz", + "integrity": "sha512-x1jLg8R+WhvkmUETRfX2wC+xJreMii78EXKLl6r3G+ggcAZlPyt0myID1Amf6hvJb9CtR7CgUo8BwR+1Vx9Ggw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@mui/base": "^5.0.0-beta.22", + "@mui/utils": "^5.14.16", + "@types/react-transition-group": "^4.4.8", + "clsx": "^2.0.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" }, "engines": { "node": ">=14.0.0" @@ -2247,14 +2383,25 @@ } } }, + "node_modules/@mui/x-internal-gestures": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.4.0.tgz", + "integrity": "sha512-i0W6v9LoiNY8Yf1goOmaygtz/ncPJGBedhpDfvNg/i8BvzPwJcBaeW4rqPucJfVag9KQ8MSssBBrvYeEnrQmhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, "node_modules/@mui/x-internals": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", - "integrity": "sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz", + "integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0" + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -2267,42 +2414,51 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@mui/x-internals/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@babel/runtime": "^7.28.6" }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", + "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" }, "engines": { - "node": ">= 8" + "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/@plotly/d3": { @@ -2334,6 +2490,48 @@ "elementary-circuits-directed-graph": "^1.0.4" } }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, "node_modules/@plotly/mapbox-gl": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", @@ -2416,16 +2614,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2437,9 +2635,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2451,9 +2649,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2465,9 +2663,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2479,9 +2677,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2493,9 +2691,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2507,9 +2705,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2521,9 +2719,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2535,9 +2733,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2549,9 +2747,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2563,9 +2761,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2577,9 +2789,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2591,9 +2817,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -2605,9 +2831,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -2619,9 +2845,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2633,9 +2859,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2647,9 +2873,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2660,10 +2886,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2675,9 +2915,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2689,9 +2929,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2703,9 +2943,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -2717,9 +2957,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2731,16 +2971,16 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz", - "integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", + "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -2756,16 +2996,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.3", - "@swc/core-darwin-x64": "1.15.3", - "@swc/core-linux-arm-gnueabihf": "1.15.3", - "@swc/core-linux-arm64-gnu": "1.15.3", - "@swc/core-linux-arm64-musl": "1.15.3", - "@swc/core-linux-x64-gnu": "1.15.3", - "@swc/core-linux-x64-musl": "1.15.3", - "@swc/core-win32-arm64-msvc": "1.15.3", - "@swc/core-win32-ia32-msvc": "1.15.3", - "@swc/core-win32-x64-msvc": "1.15.3" + "@swc/core-darwin-arm64": "1.15.18", + "@swc/core-darwin-x64": "1.15.18", + "@swc/core-linux-arm-gnueabihf": "1.15.18", + "@swc/core-linux-arm64-gnu": "1.15.18", + "@swc/core-linux-arm64-musl": "1.15.18", + "@swc/core-linux-x64-gnu": "1.15.18", + "@swc/core-linux-x64-musl": "1.15.18", + "@swc/core-win32-arm64-msvc": "1.15.18", + "@swc/core-win32-ia32-msvc": "1.15.18", + "@swc/core-win32-x64-msvc": "1.15.18" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -2777,9 +3017,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz", - "integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", + "integrity": "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==", "cpu": [ "arm64" ], @@ -2794,9 +3034,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz", - "integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.18.tgz", + "integrity": "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==", "cpu": [ "x64" ], @@ -2811,9 +3051,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz", - "integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.18.tgz", + "integrity": "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==", "cpu": [ "arm" ], @@ -2828,9 +3068,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz", - "integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.18.tgz", + "integrity": "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==", "cpu": [ "arm64" ], @@ -2845,9 +3085,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz", - "integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.18.tgz", + "integrity": "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==", "cpu": [ "arm64" ], @@ -2862,9 +3102,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz", - "integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.18.tgz", + "integrity": "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==", "cpu": [ "x64" ], @@ -2879,9 +3119,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz", - "integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.18.tgz", + "integrity": "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==", "cpu": [ "x64" ], @@ -2896,9 +3136,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz", - "integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.18.tgz", + "integrity": "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==", "cpu": [ "arm64" ], @@ -2913,9 +3153,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz", - "integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.18.tgz", + "integrity": "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==", "cpu": [ "ia32" ], @@ -2930,9 +3170,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz", - "integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.18.tgz", + "integrity": "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==", "cpu": [ "x64" ], @@ -2963,14 +3203,228 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/history": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.4.tgz", + "integrity": "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.166.7.tgz", + "integrity": "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/react-store": "^0.9.1", + "@tanstack/router-core": "1.166.7", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.2.tgz", + "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.166.7.tgz", + "integrity": "sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/store": "^0.9.1", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.166.7.tgz", + "integrity": "sha512-lBI0VS7J1zMrJhfvT+3FMq9jPdOrJ3VgciPXyYvZBF/a9Mr8T94MU78PqrBNuJbYh7qCFO14ZhArUFqkYGuozQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-core": "1.166.7", + "@tanstack/router-utils": "1.161.4", + "@tanstack/virtual-file-routes": "1.161.4", + "prettier": "^3.5.0", + "recast": "^0.23.11", + "source-map": "^0.7.4", + "tsx": "^4.19.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.166.7.tgz", + "integrity": "sha512-R06qe5UwApb/u02wDITVxN++6QE4xsLFQCr029VZ+4V8gyIe35kr8UCg3Jiyl6D5GXxhj62U2Ei8jccdkQaivw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.166.7", + "@tanstack/router-generator": "1.166.7", + "@tanstack/router-utils": "1.161.4", + "@tanstack/virtual-file-routes": "1.161.4", + "chokidar": "^3.6.0", + "unplugin": "^2.1.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2", + "@tanstack/react-router": "^1.166.7", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", + "vite-plugin-solid": "^2.11.10", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.4.tgz", + "integrity": "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.161.4.tgz", + "integrity": "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@turf/area": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", - "integrity": "sha512-zuTTdQ4eoTI9nSSjerIy4QwgvxqwJVciQJ8tOPuMHbXJ9N/dNjI7bU8tasjhxas/Cx3NE9NxVHtNpYHL0FSzoA==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.4.tgz", + "integrity": "sha512-UEQQFw2XwHpozSBAMEtZI3jDsAad4NnHL/poF7/S6zeDCjEBCkt3MYd6DSGH/cvgcOozxH/ky3/rIVSMZdx4vA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -2979,13 +3433,13 @@ } }, "node_modules/@turf/bbox": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.2.0.tgz", - "integrity": "sha512-wzHEjCXlYZiDludDbXkpBSmv8Zu6tPGLmJ1sXQ6qDwpLE1Ew3mcWqt8AaxfTP5QwDNQa3sf2vvgTEzNbPQkCiA==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.4.tgz", + "integrity": "sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -2994,13 +3448,13 @@ } }, "node_modules/@turf/centroid": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.2.0.tgz", - "integrity": "sha512-yJqDSw25T7P48au5KjvYqbDVZ7qVnipziVfZ9aSo7P2/jTE7d4BP21w0/XLi3T/9bry/t9PR1GDDDQljN4KfDw==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.4.tgz", + "integrity": "sha512-6c3kyTSKBrmiPMe75UkHw6MgedroZ6eR5usEvdlDhXgA3MudFPXIZkMFmMd1h9XeJ9xFfkmq+HPCdF0cOzvztA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -3009,9 +3463,9 @@ } }, "node_modules/@turf/helpers": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", - "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz", + "integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==", "license": "MIT", "dependencies": { "@types/geojson": "^7946.0.10", @@ -3022,28 +3476,35 @@ } }, "node_modules/@turf/meta": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.2.0.tgz", - "integrity": "sha512-igzTdHsQc8TV1RhPuOLVo74Px/hyPrVgVOTgjWQZzt3J9BVseCdpfY/0cJBdlSRI4S/yTmmHl7gAqjhpYH5Yaw==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz", + "integrity": "sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@types/geojson": "^7946.0.10" + "@turf/helpers": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, "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==", + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { @@ -3071,9 +3532,9 @@ } }, "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==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -3085,6 +3546,12 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "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", @@ -3186,9 +3653,9 @@ "license": "MIT" }, "node_modules/@types/leaflet": { - "version": "1.9.16", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.16.tgz", - "integrity": "sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==", + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3213,9 +3680,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3234,9 +3701,9 @@ "license": "MIT" }, "node_modules/@types/plotly.js": { - "version": "2.35.2", - "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.2.tgz", - "integrity": "sha512-tn0Kp7F6VWiu96jknCvR/PcdIGIATeIK+Z5WXH3bEvG6CRwUNfhy34yBhfPYmTea7mMQxXvTZKGMm6/Y4wxESg==", + "version": "2.35.14", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.14.tgz", + "integrity": "sha512-CcD/32JcK19+xWH4FFpmYez/5X9kOjUcBr8Hxh7gQ/3Z32gIoLLy/L9xvC7DG5YikPvJjq6QN05B9+MCRu/Ncw==", "dev": true, "license": "MIT" }, @@ -3247,13 +3714,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-data-grid": { @@ -3268,9 +3735,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", - "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3278,9 +3745,9 @@ } }, "node_modules/@types/react-plotly.js": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.3.tgz", - "integrity": "sha512-HBQwyGuu/dGXDsWhnQrhH+xcJSsHvjkwfSRjP+YpOsCCWryIuXF78ZCBjpfgO3sCc0Jo8sYp4NOGtqT7Cn3epQ==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.4.tgz", + "integrity": "sha512-AU6w1u3qEGM0NmBA69PaOgNc0KPFA/+qkH6Uu9EBTJ45/WYOUoXi9AF5O15PRM2klpHSiHAAs4WnlI+OZAFmUA==", "dev": true, "license": "MIT", "dependencies": { @@ -3314,9 +3781,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -3331,21 +3798,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", - "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/type-utils": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3355,23 +3821,56 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", - "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3381,19 +3880,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", - "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3403,17 +3901,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", - "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3423,14 +3939,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", - "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "dev": true, "license": "MIT", "engines": { @@ -3442,20 +3958,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", - "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3465,46 +3982,72 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3514,19 +4057,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", - "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3536,15 +4079,28 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", - "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", + "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.47", - "@swc/core": "^1.13.5" + "@rolldown/pluginutils": "1.0.0-rc.2", + "@swc/core": "^1.15.11" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -3740,23 +4296,10 @@ "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", "license": "MIT" }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3765,6 +4308,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -3776,9 +4332,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3811,9 +4367,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -3891,9 +4447,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -3917,6 +4473,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -3985,6 +4565,32 @@ "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4015,6 +4621,18 @@ "node": ">= 0.6.0" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bezier-easing": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", @@ -4030,6 +4648,19 @@ "node": ">=0.6" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binary-search-bounds": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", @@ -4081,9 +4712,9 @@ } }, "node_modules/boxen/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4093,9 +4724,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4132,9 +4763,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -4150,12 +4781,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -4171,9 +4802,9 @@ "license": "MIT" }, "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4201,9 +4832,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001702", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", - "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "funding": [ { "type": "opencollective", @@ -4218,8 +4849,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0", - "peer": true + "license": "CC-BY-4.0" }, "node_modules/canvas-fit": { "version": "1.5.0", @@ -4261,6 +4891,31 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -4445,17 +5100,17 @@ } }, "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", + "bytes": "3.1.2", + "compressible": "~2.0.18", "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", "vary": "~1.1.2" }, "engines": { @@ -4477,12 +5132,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4519,6 +5168,12 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4618,19 +5273,19 @@ "license": "MIT" }, "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.33", + "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" + "semver": "^7.6.3" }, "engines": { "node": ">= 18.12.0" @@ -4640,7 +5295,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "peerDependenciesMeta": { @@ -4652,6 +5307,18 @@ } } }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/css-system-font-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", @@ -4677,9 +5344,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d": { @@ -4696,10 +5363,16 @@ } }, "node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" + "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-collection": { "version": "1.0.7", @@ -4716,18 +5389,6 @@ "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", @@ -4746,12 +5407,21 @@ "d3-timer": "1" } }, - "node_modules/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "node_modules/d3-force/node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", "license": "BSD-3-Clause" }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-geo": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", @@ -4780,6 +5450,18 @@ "geostitch": "bin/geostitch" } }, + "node_modules/d3-geo-projection/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, "node_modules/d3-hierarchy": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", @@ -4799,10 +5481,13 @@ } }, "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" + "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/d3-quadtree": { "version": "1.0.7", @@ -4826,19 +5511,19 @@ "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==", + "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": { - "internmap": "1 - 2" + "d3-path": "^3.1.0" }, "engines": { "node": ">=12" } }, - "node_modules/d3-scale/node_modules/d3-time": { + "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==", @@ -4850,46 +5535,37 @@ "node": ">=12" } }, - "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", - "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", - "license": "BSD-3-Clause" - }, "node_modules/d3-time-format": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", - "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", - "license": "BSD-3-Clause", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { - "d3-time": "1" + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, "node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" + "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/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4928,15 +5604,6 @@ "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", @@ -4949,6 +5616,16 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5019,11 +5696,10 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.113", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", - "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", - "license": "ISC", - "peer": true + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "license": "ISC" }, "node_modules/element-size": { "version": "1.1.1", @@ -5047,41 +5723,41 @@ "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "license": "MIT", "peer": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT", "peer": true }, @@ -5138,9 +5814,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5151,31 +5827,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -5183,7 +5860,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5232,33 +5908,32 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5270,7 +5945,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5306,9 +5981,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", - "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5316,9 +5991,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5333,9 +6008,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5345,6 +6020,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esniff": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", @@ -5361,15 +6049,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5392,9 +6080,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5533,36 +6221,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-isnumeric": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", @@ -5587,9 +6245,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -5600,18 +6258,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } + "license": "BSD-3-Clause" }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -5676,10 +6323,16 @@ "node": ">=16" } }, + "node_modules/flatqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", + "integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==", + "license": "ISC" + }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -5750,6 +6403,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -5774,6 +6437,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gl-mat4": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", @@ -5781,9 +6457,9 @@ "license": "Zlib" }, "node_modules/gl-matrix": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", - "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", "license": "MIT" }, "node_modules/gl-text": { @@ -5830,7 +6506,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5848,16 +6524,16 @@ } }, "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.3" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=10.13.0" + "node": ">= 6" } }, "node_modules/glob-to-regexp": { @@ -5882,12 +6558,12 @@ } }, "node_modules/global-prefix/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/global-prefix/node_modules/which": { @@ -6120,9 +6796,9 @@ } }, "node_modules/goober": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", - "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", "license": "MIT", "peerDependencies": { "csstype": "^3.0.10" @@ -6134,13 +6810,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/grid-index": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", @@ -6271,9 +6940,9 @@ } }, "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", "funding": { "type": "opencollective", @@ -6347,6 +7016,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-browser": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", @@ -6533,6 +7215,15 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/isbot": { + "version": "5.1.36", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.36.tgz", + "integrity": "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6729,6 +7420,19 @@ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jwt-decode": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", @@ -6796,13 +7500,17 @@ "license": "MIT" }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", "peer": true, "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -6839,6 +7547,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/map-limit": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", @@ -6933,9 +7651,9 @@ } }, "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", - "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", "license": "BSD-2-Clause" }, "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { @@ -6945,9 +7663,9 @@ "license": "BSD-2-Clause" }, "node_modules/maplibre-gl/node_modules/earcut": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", - "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, "node_modules/maplibre-gl/node_modules/geojson-vt": { @@ -6957,9 +7675,9 @@ "license": "ISC" }, "node_modules/maplibre-gl/node_modules/potpack": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", - "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC" }, "node_modules/maplibre-gl/node_modules/quickselect": { @@ -7008,16 +7726,6 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7039,30 +7747,30 @@ "license": "MIT" }, "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "~1.33.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7078,9 +7786,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7219,9 +7927,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7241,11 +7949,20 @@ "license": "ISC" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=0.10.0" + } }, "node_modules/normalize-svg-path": { "version": "0.1.0", @@ -7324,9 +8041,9 @@ "license": "MIT" }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -7518,6 +8235,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -7622,6 +8346,27 @@ "world-calendars": "^1.0.3" } }, + "node_modules/plotly.js/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, "node_modules/point-in-polygon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", @@ -7635,9 +8380,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -7722,9 +8467,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -7756,6 +8501,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -7841,32 +8602,12 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", @@ -7882,16 +8623,6 @@ "performance-now": "^2.1.0" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -7983,9 +8714,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.54.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", - "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -7999,9 +8730,9 @@ } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, "node_modules/react-leaflet": { @@ -8019,9 +8750,9 @@ } }, "node_modules/react-number-format": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.3.tgz", - "integrity": "sha512-VCY5hFg/soBighAoGcdE+GagkJq0230qN6jcS5sp8wQX1qy1fYN/RX7/BXkrs0oyzzwqR8/+eSUrqXbGeywdUQ==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -8142,6 +8873,46 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", @@ -8266,12 +9037,12 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -8294,6 +9065,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", @@ -8303,17 +9084,6 @@ "protocol-buffers-schema": "^3.3.1" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/right-now": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", @@ -8336,16 +9106,10 @@ "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.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -8359,55 +9123,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -8441,10 +9184,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/scheduler": { "version": "0.23.2", @@ -8456,9 +9202,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "peer": true, "dependencies": { @@ -8476,9 +9222,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -8513,43 +9259,52 @@ "peer": true }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, + } + }, + "node_modules/seroval": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", + "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", + "license": "MIT", "engines": { "node": ">=10" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" + "node_modules/seroval-plugins": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.1.tgz", + "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" } }, "node_modules/serve": { - "version": "14.2.4", - "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz", - "integrity": "sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==", + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", "license": "MIT", "dependencies": { "@zeit/schemas": "2.36.0", - "ajv": "8.12.0", + "ajv": "8.18.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", - "compression": "1.7.4", + "compression": "1.8.1", "is-port-reachable": "4.0.0", - "serve-handler": "6.1.6", + "serve-handler": "6.1.7", "update-check": "1.5.4" }, "bin": { @@ -8560,51 +9315,39 @@ } }, "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", "license": "MIT", "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", - "minimatch": "3.1.2", + "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, - "node_modules/serve-handler/node_modules/mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-handler/node_modules/mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", "license": "MIT", - "dependencies": { - "mime-db": "~1.33.0" - }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/serve/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -8829,12 +9572,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -8979,24 +9722,28 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "peer": true, "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -9008,16 +9755,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -9058,6 +9804,18 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -9167,9 +9925,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", - "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -9185,6 +9943,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", @@ -9233,9 +10011,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9247,15 +10025,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.0.tgz", - "integrity": "sha512-PtVz9nAnuNJuAVeUFvwztjuUgSnJInODAUx47VDwWPXzd5vismPOtPtt83tzNXyOjVQbPRp786D6WFW/M2koIA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "@typescript-eslint/utils": "8.26.0" + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9265,8 +10044,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/undici-types": { @@ -9285,6 +10064,35 @@ "detect-node": "^2.0.4" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", @@ -9292,9 +10100,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -9310,7 +10118,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -9342,6 +10149,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -9360,9 +10168,9 @@ } }, "node_modules/use-sync-external-store": { - "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -9384,13 +10192,13 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -9501,9 +10309,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "peer": true, "dependencies": { @@ -9530,35 +10338,37 @@ } }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -9577,15 +10387,22 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", "peer": true, "engines": { "node": ">=10.13.0" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -9610,6 +10427,29 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9651,9 +10491,9 @@ } }, "node_modules/world-calendars": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.3.tgz", - "integrity": "sha512-sAjLZkBnsbHkHWVhrsCU5Sa/EVuf9QqgvrN8zyJ2L/F9FR9Oc6CvVK0674+PGAtmmmYQMH98tCUSO4QLQv3/TQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", "license": "MIT", "dependencies": { "object-assign": "^4.1.0" @@ -9677,9 +10517,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -9703,10 +10543,17 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "optional": true, @@ -9715,7 +10562,10 @@ "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { @@ -9732,9 +10582,9 @@ } }, "node_modules/yup": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", - "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", "dependencies": { "property-expr": "^2.0.5", @@ -9742,6 +10592,16 @@ "toposort": "^2.0.2", "type-fest": "^2.19.0" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 07e2ae50..d398cab1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", + "@tanstack/react-router": "^1.99.7", "dayjs": "^1.11.9", "immer": "^10.0.2", "js-yaml": "^4.1.1", @@ -36,14 +37,13 @@ "react-number-format": "^5.3.1", "react-plotly.js": "^2.6.0", "react-query": "^3.39.3", - "react-router": "^6.30.3", - "react-router-dom": "^6.30.3", "serve": "^14.0.1", "use-debounce": "^9.0.4", "yup": "^1.2.0" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@tanstack/router-plugin": "^1.99.7", "@types/geojson": "^7946.0.14", "@types/jest": "^29.5.2", "@types/leaflet": "^1.9.16", @@ -62,4 +62,4 @@ "typescript-eslint": "^8.24.1", "vite": "^7.2.6" } -} +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b453bc47..d3eebc19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,42 +1,15 @@ -import { useEffect, useState } from "react"; import { AuthProvider } from "react-auth-kit"; -import { Route, BrowserRouter as Router, Routes } from "react-router-dom"; +import { RouterProvider } from "@tanstack/react-router"; 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 { BackupsView, Home, Login, Settings } from "./views"; -import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; -import { ActivitiesView } from "./views/Activities/ActivitiesView"; -import { ActivityPhotoView } from "./views/Activities/ActivityPhotoView"; -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 { NotFound } from "./views/NotFound"; -import { ProtectedRoute } from "./ProtectedRoute"; +import { SnackbarProvider } from "notistack"; +import { router } from "./router"; +import { ErrorMessageProvider } from "./contexts/ErrorMessageContext"; -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 - const [errorMessage, setErrorMessage] = useState(); - useEffect(() => { - if (errorMessage) { - enqueueSnackbar(errorMessage, { variant: "error" }); - } - }, [errorMessage]); +const queryClient = new QueryClient(); +export const App = () => { return ( @@ -50,258 +23,9 @@ export const App = () => { cookieDomain={window.location.hostname} cookieSecure={window.location.protocol === "https:"} > - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - } - /> - - + + + diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 0a9aaf78..67ec5ab9 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -1,41 +1,120 @@ -import { useState } from "react"; -import { Box } from "@mui/material"; -import Topbar from "./components/Topbar"; +import { useEffect, useState } from "react"; +import { Box, useMediaQuery, useTheme } from "@mui/material"; +import { Topbar } from "@/components"; +import { DESKTOP_COLLAPSED_WIDTH, SidebarInset } from "@/components/ui/sidebar"; import Sidenav from "./sidenav"; +import { useAuthUser } from "react-auth-kit"; -const drawerWidth = 250; +const defaultSidebarWidth = 280; +const sidebarOpenStorageKey = "wmdb.sidebar.open"; +const sidebarWidthStorageKey = "wmdb.sidebar.width"; -export const AppLayout = ({ - children, -}: { - children: JSX.Element -}) => { - const [drawerOpen, setDrawerOpen] = useState(false); +const readStoredSidebarOpen = () => { + if (typeof window === "undefined") return true; + + const raw = window.localStorage.getItem(sidebarOpenStorageKey); + if (raw === null) return true; + return raw === "true"; +}; + +const readStoredSidebarWidth = () => { + if (typeof window === "undefined") return defaultSidebarWidth; + + const raw = window.localStorage.getItem(sidebarWidthStorageKey); + const parsed = raw ? Number(raw) : NaN; + + if (!Number.isFinite(parsed) || parsed <= DESKTOP_COLLAPSED_WIDTH) { + return defaultSidebarWidth; + } + + return parsed; +}; + +export const AppLayout = ({ children }: { children: JSX.Element }) => { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); + const [drawerOpen, setDrawerOpen] = useState(readStoredSidebarOpen); + const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth); + const authUser = useAuthUser(); + const isLoggedIn = !!authUser(); + const shouldRenderSidebar = !isDesktop || isLoggedIn; + const shouldShowDesktopSidebar = isDesktop && isLoggedIn; + + useEffect(() => { + if (!isDesktop) { + setDrawerOpen(false); + return; + } + + setDrawerOpen(readStoredSidebarOpen()); + }, [isDesktop]); + + useEffect(() => { + if (!isDesktop || typeof window === "undefined") return; + window.localStorage.setItem(sidebarOpenStorageKey, String(drawerOpen)); + }, [drawerOpen, isDesktop]); + + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem(sidebarWidthStorageKey, String(sidebarWidth)); + }, [sidebarWidth]); + + const effectiveSidebarWidth = shouldShowDesktopSidebar + ? drawerOpen + ? sidebarWidth + : DESKTOP_COLLAPSED_WIDTH + : 0; return ( - + setDrawerOpen(!drawerOpen)} + sidebarWidth={sidebarWidth} + onMenuClick={() => setDrawerOpen((prev) => !prev)} /> - setDrawerOpen(false)} - /> - setDrawerOpen(false)} + onOpen={() => setDrawerOpen(true)} + onWidthChange={(width) => { + setSidebarWidth(width); + if (!drawerOpen) { + setDrawerOpen(true); + } + }} + /> + ) : null} + + - {children} - - + + {children} + + + ); }; - diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx index d607e2e6..cced2860 100644 --- a/frontend/src/ProtectedRoute.tsx +++ b/frontend/src/ProtectedRoute.tsx @@ -1,22 +1,22 @@ -import { Navigate } from "react-router-dom"; +import { Navigate } from "@tanstack/react-router"; import { useAuthUser, useIsAuthenticated } from "react-auth-kit"; import { SecurityScope } from "./interfaces"; +import { useErrorMessage } from "./contexts/ErrorMessageContext"; export const ProtectedRoute = ({ children, requiredScopes, - setErrorMessage, }: { children: JSX.Element; requiredScopes?: string[]; - setErrorMessage?: (msg: string) => void; }) => { const isAuthenticated = useIsAuthenticated(); const authUser = useAuthUser(); + const { setErrorMessage } = useErrorMessage(); // Case 1: Not logged in if (!isAuthenticated()) { - if (setErrorMessage) setErrorMessage("You must login to view this page."); + setErrorMessage("You must login to view this page."); return ; } @@ -27,15 +27,13 @@ export const ProtectedRoute = ({ ) ?? []; if (userScopes.length === 0) { - if (setErrorMessage) - setErrorMessage("Your account does not have any permissions."); + setErrorMessage("Your account does not have any permissions."); return ; } // Case 3: Missing required scopes if (requiredScopes && !requiredScopes.every((s) => userScopes.includes(s))) { - if (setErrorMessage) - setErrorMessage("You do not have sufficient permissions."); + setErrorMessage("You do not have sufficient permissions."); return ; } diff --git a/frontend/src/components/BackgroundBox.tsx b/frontend/src/components/BackgroundBox.tsx index a2c5bd4f..4c02ed9a 100644 --- a/frontend/src/components/BackgroundBox.tsx +++ b/frontend/src/components/BackgroundBox.tsx @@ -9,8 +9,8 @@ export const BackgroundBox: React.FC = ({ return ( event.stopPropagation()} /> } diff --git a/frontend/src/components/CustomCardHeader.tsx b/frontend/src/components/CustomCardHeader.tsx index 38e24031..24667416 100644 --- a/frontend/src/components/CustomCardHeader.tsx +++ b/frontend/src/components/CustomCardHeader.tsx @@ -8,7 +8,7 @@ import { } from "@mui/material"; type CustomCardHeaderProps = Omit & { - title?: string; + title?: React.ReactNode; icon?: React.ComponentType; }; @@ -27,7 +27,8 @@ export const CustomCardHeader: React.FC = ({ flexDirection: "row", alignItems: "center", color: "white", - background: "#292929", + // background: "#292929", + background: "#333", borderRadius: "5px", px: "14px", py: "10px", diff --git a/frontend/src/components/EventTypeChip.tsx b/frontend/src/components/EventTypeChip.tsx new file mode 100644 index 00000000..e9d63d8d --- /dev/null +++ b/frontend/src/components/EventTypeChip.tsx @@ -0,0 +1,60 @@ +import { Chip } from "@mui/material"; + +export const EventTypeChip = ({ + event_type, +}: { + event_type: "added" | "used" | "initial" | "current" | string; +}) => { + switch (event_type) { + case "added": { + return ( + + ); + } + case "used": { + return ( + + ); + } + case "initial": { + return ( + + ); + } + case "current": { + return ( + + ); + } + default: { + return ( + + ); + } + } +}; diff --git a/frontend/src/components/GridFooterWithButton.tsx b/frontend/src/components/GridFooterWithButton.tsx index 676bf7ab..1977a5f2 100644 --- a/frontend/src/components/GridFooterWithButton.tsx +++ b/frontend/src/components/GridFooterWithButton.tsx @@ -2,15 +2,11 @@ import { Box } from "@mui/material"; import { GridPagination } from "@mui/x-data-grid"; import { ReactNode } from "react"; -export default function GridFooterWithButton({ - button, -}: { - button: ReactNode; -}) { +export const GridFooterWithButton = ({ button }: { button: ReactNode }) => { return ( {button} ); -} +}; diff --git a/frontend/src/components/ImageDialog.tsx b/frontend/src/components/ImageDialog.tsx index 11e4f2e3..7004534d 100644 --- a/frontend/src/components/ImageDialog.tsx +++ b/frontend/src/components/ImageDialog.tsx @@ -1,5 +1,5 @@ import { Dialog, DialogContent, IconButton, Box } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; +import { Close } from "@mui/icons-material"; export const ImageDialog = ({ open, @@ -25,7 +25,7 @@ export const ImageDialog = ({ "&:hover": { backgroundColor: "rgba(0,0,0,0.8)" }, }} > - + {src && ( void; - onOpen?: (src: string) => void; -}) => { - return ( - - {previews.map((src, i) => { - return ( - onOpen?.(src)} - > +export const ImagePreviewGrid = memo( + ({ + previews, + onRemove, + onOpen, + }: { + previews: string[]; + onRemove?: (index: number) => void; + onOpen?: (src: string) => void; + }) => { + return ( + + {previews.map((src, i) => { + return ( - {onRemove && ( - onRemove(i)} + onDoubleClick={() => onOpen?.(src)} + > + - - - )} - - ); - })} - - ); -}); + /> + {onRemove && ( + onRemove(i)} + sx={{ + position: "absolute", + top: 0, + right: 0, + backgroundColor: "rgba(255,255,255,0.7)", + border: "1px solid black", + "&:hover": { + backgroundColor: "rgba(255,0,0,0.8)", + color: "white", + }, + }} + > + + + )} + + ); + })} + + ); + }, +); diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx index 98abf5de..efefffe6 100644 --- a/frontend/src/components/ImageUploadWithPreview.tsx +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -7,10 +7,7 @@ import { enqueueSnackbar } from "notistack"; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const VisuallyHiddenInput = (props: any) => ( - + ); export const ImageUploadWithPreview = ({ @@ -30,7 +27,7 @@ export const ImageUploadWithPreview = ({ if (!files) return; let imageFiles = Array.from(files).filter((file) => - file.type.startsWith("image/") + file.type.startsWith("image/"), ); // enforce max file size @@ -38,7 +35,7 @@ export const ImageUploadWithPreview = ({ if (tooBig.length > 0) { enqueueSnackbar( `Some files are too large. Max allowed size is ${MAX_FILE_SIZE / 1024 / 1024} MB.`, - { variant: "error" } + { variant: "error" }, ); imageFiles = imageFiles.filter((f) => f.size <= MAX_FILE_SIZE); } @@ -59,9 +56,12 @@ export const ImageUploadWithPreview = ({ } if (imageFiles.length > remaining) { - enqueueSnackbar(`Only ${remaining} more image${remaining > 1 ? "s" : ""} allowed.`, { - variant: "info", - }); + enqueueSnackbar( + `Only ${remaining} more image${remaining > 1 ? "s" : ""} allowed.`, + { + variant: "info", + }, + ); imageFiles = imageFiles.slice(0, remaining); } } @@ -104,7 +104,7 @@ export const ImageUploadWithPreview = ({ startIcon={} disabled={fileLimit !== undefined && files.length >= fileLimit} // disable when limit reached > - Upload photos + {`Upload photo${(fileLimit ?? 0) >= 2 ? "s" : ""}`} {fileLimit && ( - {files.length}/{fileLimit} images uploaded + {files.length}/{fileLimit} + {` image${(fileLimit ?? 0) >= 2 ? "s" : ""} uploaded`} )} @@ -137,5 +138,4 @@ export const ImageUploadWithPreview = ({ )} ); -} - +}; diff --git a/frontend/src/components/Layers/BoundariesLayer.tsx b/frontend/src/components/Layers/BoundariesLayer.tsx new file mode 100644 index 00000000..f22096cb --- /dev/null +++ b/frontend/src/components/Layers/BoundariesLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet"; + +export const BoundariesLayer = ({ checked = false }: { checked?: boolean }) => ( + + + +); diff --git a/frontend/src/components/Layers/OpenStreetMapLayer.tsx b/frontend/src/components/Layers/OpenStreetMapLayer.tsx index d6dfaf91..3ee6525d 100644 --- a/frontend/src/components/Layers/OpenStreetMapLayer.tsx +++ b/frontend/src/components/Layers/OpenStreetMapLayer.tsx @@ -1,10 +1,10 @@ -import { LayersControl, TileLayer } from "react-leaflet" +import { LayersControl, TileLayer } from "react-leaflet"; -export const OpenStreetMapLayer = () => ( - +export const OpenStreetMapLayer = ({ checked = false }: { checked?: boolean }) => ( + -) +); diff --git a/frontend/src/components/Layers/SatelliteLayer.tsx b/frontend/src/components/Layers/SatelliteLayer.tsx index d22221ca..dabb737d 100644 --- a/frontend/src/components/Layers/SatelliteLayer.tsx +++ b/frontend/src/components/Layers/SatelliteLayer.tsx @@ -1,10 +1,10 @@ -import { LayersControl, TileLayer } from "react-leaflet" +import { LayersControl, TileLayer } from "react-leaflet"; -export const SatelliteLayer = () => ( - +export const SatelliteLayer = ({ checked = false }: { checked?: boolean }) => ( + -) +); diff --git a/frontend/src/components/Layers/SoutheastGuideLayer.tsx b/frontend/src/components/Layers/SoutheastGuideLayer.tsx index 46ec12d6..e921bab3 100644 --- a/frontend/src/components/Layers/SoutheastGuideLayer.tsx +++ b/frontend/src/components/Layers/SoutheastGuideLayer.tsx @@ -52,9 +52,13 @@ const labelIcon = (text: string) => ">${text}`, }); -export const SoutheastGuideLayer = () => +export const SoutheastGuideLayer = ({ + checked = false, +}: { + checked?: boolean; +}) => ( - + {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} diff --git a/frontend/src/components/Layers/TransportationLayer.tsx b/frontend/src/components/Layers/TransportationLayer.tsx new file mode 100644 index 00000000..6a6cbcf8 --- /dev/null +++ b/frontend/src/components/Layers/TransportationLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet"; + +export const TransporationLayer = ({ checked = false }: { checked?: boolean }) => ( + + + +); diff --git a/frontend/src/components/Layers/index.ts b/frontend/src/components/Layers/index.ts index a4be50d8..e29313ec 100644 --- a/frontend/src/components/Layers/index.ts +++ b/frontend/src/components/Layers/index.ts @@ -1,4 +1,5 @@ -export * from './SoutheastGuideLayer' -export * from './SatelliteLayer' -export * from './OpenStreetMapLayer' - +export * from "./SoutheastGuideLayer"; +export * from "./SatelliteLayer"; +export * from "./BoundariesLayer"; +export * from "./TransportationLayer"; +export * from "./OpenStreetMapLayer"; diff --git a/frontend/src/components/LinkBehavior.tsx b/frontend/src/components/LinkBehavior.tsx new file mode 100644 index 00000000..e32b008f --- /dev/null +++ b/frontend/src/components/LinkBehavior.tsx @@ -0,0 +1,17 @@ +import { Link as RouterLink, createLink } from "@tanstack/react-router"; +import { Link as MuiLink, type LinkProps as MuiLinkProps } from "@mui/material"; +import { forwardRef } from "react"; + +// MUI expects the component to forwardRef to an element +export const LinkBehavior = forwardRef< + HTMLAnchorElement, + React.ComponentProps +>(function LinkBehavior(props, ref) { + return ; +}); + +const MUILinkComponent = forwardRef( + (props, ref) => , +); + +export const RouterMuiLink = createLink(MUILinkComponent); diff --git a/frontend/src/components/ManageBreadcrumbTitle.tsx b/frontend/src/components/ManageBreadcrumbTitle.tsx new file mode 100644 index 00000000..05304fc2 --- /dev/null +++ b/frontend/src/components/ManageBreadcrumbTitle.tsx @@ -0,0 +1,58 @@ +import DashboardCustomizeOutlinedIcon from "@mui/icons-material/DashboardCustomizeOutlined"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import { Box, Breadcrumbs, Link as MuiLink, Typography } from "@mui/material"; +import { Link as RouterLink } from "@tanstack/react-router"; + +export const ManageBreadcrumbTitle = ({ current }: { current: string }) => { + return ( + } + sx={{ + color: "inherit", + "& .MuiBreadcrumbs-ol": { + alignItems: "center", + }, + "& .MuiBreadcrumbs-separator": { + display: "inline-flex", + alignItems: "center", + color: "rgba(255, 255, 255, 0.72)", + mx: 1, + }, + }} + > + + + Manage + + + {current} + + + ); +}; diff --git a/frontend/src/components/MapFullscreenToggle.tsx b/frontend/src/components/MapFullscreenToggle.tsx new file mode 100644 index 00000000..702bbdad --- /dev/null +++ b/frontend/src/components/MapFullscreenToggle.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState, type RefObject } from "react"; +import { Fullscreen, FullscreenExit } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; +import { useMap } from "react-leaflet"; + +type MapFullscreenToggleProps = { + containerRef: RefObject; +}; + +const getFullscreenElement = () => + document.fullscreenElement as HTMLElement | null; + +const MapFullscreenSync = () => { + const map = useMap(); + + useEffect(() => { + const syncMapSize = () => { + window.setTimeout(() => { + map.invalidateSize(); + }, 0); + }; + + document.addEventListener("fullscreenchange", syncMapSize); + window.addEventListener("resize", syncMapSize); + + return () => { + document.removeEventListener("fullscreenchange", syncMapSize); + window.removeEventListener("resize", syncMapSize); + }; + }, [map]); + + return null; +}; + +export const MapFullscreenToggle = ({ + containerRef, +}: MapFullscreenToggleProps) => { + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const syncFullscreenState = () => { + setIsFullscreen(getFullscreenElement() === containerRef.current); + }; + + syncFullscreenState(); + document.addEventListener("fullscreenchange", syncFullscreenState); + + return () => { + document.removeEventListener("fullscreenchange", syncFullscreenState); + }; + }, [containerRef]); + + const handleToggleFullscreen = async () => { + const container = containerRef.current; + + if (!container) return; + + if (getFullscreenElement() === container) { + await document.exitFullscreen(); + return; + } + + await container.requestFullscreen(); + }; + + return ( + <> + + + { + void handleToggleFullscreen(); + }} + sx={{ + position: "absolute", + top: 10, + right: 10, + zIndex: 1000, + backgroundColor: "rgba(255, 255, 255, 0.95)", + border: "1px solid rgba(0, 0, 0, 0.2)", + boxShadow: "0 1px 5px rgba(0, 0, 0, 0.35)", + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 1)", + }, + }} + > + {isFullscreen ? : } + + + + ); +}; diff --git a/frontend/src/components/MapUrlStateSync.tsx b/frontend/src/components/MapUrlStateSync.tsx new file mode 100644 index 00000000..2fdb8a23 --- /dev/null +++ b/frontend/src/components/MapUrlStateSync.tsx @@ -0,0 +1,220 @@ +import { useEffect, useMemo, useRef } from "react"; +import type { LayersControlEvent } from "leaflet"; +import { useMap } from "react-leaflet"; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + normalizeMapBaseLayer, + normalizeMapOverlayNames, + parseMapView, + serializeMapView, +} from "@/utils"; + +type MapSearchState = { + mapBase?: string; + mapOverlays?: string[]; + mapLat?: number; + mapLng?: number; + mapZoom?: number; +}; + +type MapUrlStateSyncProps = { + allowedBaseLayers: readonly string[]; + allowedOverlays: readonly string[]; + defaultBaseLayer: string; + defaultOverlays: string[]; + search: TSearch; + setSearch: (updater: (prev: TSearch) => TSearch) => void; +}; + +const sortNames = (names: string[]) => [...names].sort(); + +const arraysEqual = (left: string[], right: string[]) => + left.length === right.length && + left.every((value, index) => value === right[index]); + +export const MapUrlStateSync = ({ + allowedBaseLayers, + allowedOverlays, + defaultBaseLayer, + defaultOverlays, + search, + setSearch, +}: MapUrlStateSyncProps) => { + const map = useMap(); + const searchRef = useRef(search); + const activeBaseLayerRef = useRef( + normalizeMapBaseLayer(search.mapBase, allowedBaseLayers, defaultBaseLayer), + ); + const activeOverlaysRef = useRef( + normalizeMapOverlayNames( + search.mapOverlays, + allowedOverlays, + defaultOverlays, + ), + ); + + const normalizedDefaults = useMemo( + () => ({ + baseLayer: normalizeMapBaseLayer( + defaultBaseLayer, + allowedBaseLayers, + defaultBaseLayer, + ), + overlays: normalizeMapOverlayNames( + defaultOverlays, + allowedOverlays, + defaultOverlays, + ), + view: { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }, + }), + [allowedBaseLayers, allowedOverlays, defaultBaseLayer, defaultOverlays], + ); + + useEffect(() => { + searchRef.current = search; + activeBaseLayerRef.current = normalizeMapBaseLayer( + search.mapBase, + allowedBaseLayers, + defaultBaseLayer, + ); + activeOverlaysRef.current = normalizeMapOverlayNames( + search.mapOverlays, + allowedOverlays, + defaultOverlays, + ); + }, [ + allowedBaseLayers, + allowedOverlays, + defaultBaseLayer, + defaultOverlays, + search, + ]); + + useEffect(() => { + const nextView = parseMapView(search, normalizedDefaults.view); + const currentCenter = map.getCenter(); + const currentZoom = map.getZoom(); + + const viewChanged = + Math.abs(currentCenter.lat - nextView.center[0]) > 0.00001 || + Math.abs(currentCenter.lng - nextView.center[1]) > 0.00001 || + currentZoom !== nextView.zoom; + + if (viewChanged) { + map.setView(nextView.center, nextView.zoom, { animate: false }); + } + }, [map, normalizedDefaults.view, search]); + + useEffect(() => { + const syncSearchFromMap = () => { + const currentSearch = searchRef.current; + const baseLayer = normalizeMapBaseLayer( + activeBaseLayerRef.current, + allowedBaseLayers, + defaultBaseLayer, + ); + const overlays = normalizeMapOverlayNames( + activeOverlaysRef.current, + allowedOverlays, + defaultOverlays, + ); + const viewState = serializeMapView( + map.getCenter(), + map.getZoom(), + normalizedDefaults.view, + ); + const nextBaseLayer = + baseLayer === normalizedDefaults.baseLayer ? undefined : baseLayer; + const nextOverlays = arraysEqual(overlays, normalizedDefaults.overlays) + ? undefined + : overlays; + const currentBaseLayer = normalizeMapBaseLayer( + currentSearch.mapBase, + allowedBaseLayers, + defaultBaseLayer, + ); + const currentOverlays = normalizeMapOverlayNames( + currentSearch.mapOverlays, + allowedOverlays, + defaultOverlays, + ); + const currentView = parseMapView(currentSearch, normalizedDefaults.view); + + const baseChanged = currentBaseLayer !== baseLayer; + const overlaysChanged = !arraysEqual(currentOverlays, overlays); + const viewChanged = + Math.abs( + currentView.center[0] - + (viewState.mapLat ?? normalizedDefaults.view.center[0]), + ) > + 0.00001 || + Math.abs( + currentView.center[1] - + (viewState.mapLng ?? normalizedDefaults.view.center[1]), + ) > + 0.00001 || + currentView.zoom !== (viewState.mapZoom ?? normalizedDefaults.view.zoom); + + if (!baseChanged && !overlaysChanged && !viewChanged) { + return; + } + + setSearch((prev) => ({ + ...prev, + mapBase: nextBaseLayer, + mapOverlays: nextOverlays, + mapLat: viewState.mapLat, + mapLng: viewState.mapLng, + mapZoom: viewState.mapZoom, + })); + }; + + const handleBaseLayerChange = (event: LayersControlEvent) => { + activeBaseLayerRef.current = event.name; + syncSearchFromMap(); + }; + + const handleOverlayAdd = (event: LayersControlEvent) => { + activeOverlaysRef.current = sortNames([ + ...activeOverlaysRef.current, + event.name, + ]); + syncSearchFromMap(); + }; + + const handleOverlayRemove = (event: LayersControlEvent) => { + activeOverlaysRef.current = sortNames( + activeOverlaysRef.current.filter((name) => name !== event.name), + ); + syncSearchFromMap(); + }; + + map.on("moveend", syncSearchFromMap); + map.on("baselayerchange", handleBaseLayerChange); + map.on("overlayadd", handleOverlayAdd); + map.on("overlayremove", handleOverlayRemove); + + return () => { + map.off("moveend", syncSearchFromMap); + map.off("baselayerchange", handleBaseLayerChange); + map.off("overlayadd", handleOverlayAdd); + map.off("overlayremove", handleOverlayRemove); + }; + }, [ + allowedBaseLayers, + allowedOverlays, + defaultBaseLayer, + defaultOverlays, + map, + normalizedDefaults.baseLayer, + normalizedDefaults.overlays, + normalizedDefaults.view, + setSearch, + ]); + + return null; +}; diff --git a/frontend/src/components/MergeWellModal.tsx b/frontend/src/components/MergeWellModal.tsx index bc7bbfdc..0afaf310 100644 --- a/frontend/src/components/MergeWellModal.tsx +++ b/frontend/src/components/MergeWellModal.tsx @@ -1,8 +1,9 @@ import { Box, Modal, Button, Grid } from "@mui/material"; import { useState, useEffect } from "react"; -import { useMergeWells } from "../service/ApiServiceNew"; +import { useMergeWells } from "@/service"; +import { Well } from "@/interfaces"; + import WellSelection from "./WellSelection"; -import { Well } from "../interfaces"; export function MergeWellModal({ isWellMergeModalOpen, diff --git a/frontend/src/components/MeterMapColorLegend.tsx b/frontend/src/components/MeterMapColorLegend.tsx index fad8515b..253e2a75 100644 --- a/frontend/src/components/MeterMapColorLegend.tsx +++ b/frontend/src/components/MeterMapColorLegend.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useLeafletContext } from "@react-leaflet/core"; import L from "leaflet"; -import { PM_COLORS } from "../constants"; +import { PM_COLORS } from "@/constants"; export const MeterMapColorLegend = () => { const context = useLeafletContext(); @@ -9,15 +9,15 @@ export const MeterMapColorLegend = () => { useEffect(() => { const legend = new L.Control({ position: "bottomleft" }); - legend.onAdd = function() { + legend.onAdd = function () { const div = L.DomUtil.create("div", "info legend"); div.style.background = "white"; - div.style.padding = "10px"; + div.style.padding = "8px"; div.style.borderRadius = "8px"; div.style.boxShadow = "0 2px 6px rgba(0,0,0,0.3)"; div.style.fontSize = "14px"; - div.style.lineHeight = "18px"; + div.style.lineHeight = "14px"; const title = L.DomUtil.create("h4", "", div); title.textContent = "PM Season"; @@ -27,7 +27,7 @@ export const MeterMapColorLegend = () => { const row = L.DomUtil.create("div", "", div); row.style.display = "flex"; row.style.alignItems = "center"; - row.style.marginBottom = "6px"; + row.style.marginBottom = "5px"; const colorBox = L.DomUtil.create("div", "", row); colorBox.style.width = "20px"; @@ -53,5 +53,4 @@ export const MeterMapColorLegend = () => { }, [context.map]); return null; -} - +}; diff --git a/frontend/src/components/MeterRegisterSelect.tsx b/frontend/src/components/MeterRegisterSelect.tsx index 08e56657..f9d2b803 100644 --- a/frontend/src/components/MeterRegisterSelect.tsx +++ b/frontend/src/components/MeterRegisterSelect.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from "react"; -import { useGetMeterRegisterList } from "../service/ApiServiceNew"; +import { useEffect, useMemo } from "react"; import { FormControl, InputLabel, @@ -7,7 +6,8 @@ import { Select, FormHelperText, } from "@mui/material"; -import { MeterRegister, MeterType } from "../interfaces"; +import { useGetMeterRegisterList } from "@/service"; +import { MeterRegister, MeterType } from "@/interfaces"; function getRegisterTitle(register: MeterRegister) { //Describing the register can be a bit complex, so this function will return a string that describes the register @@ -35,23 +35,17 @@ export default function MeterRegisterSelect({ helperText?: string; }) { const meterRegisterList = useGetMeterRegisterList(); - const [filteredRegisterList, setFilteredRegisterList] = useState< - MeterRegister[] | undefined - >([]); //Filter the register list based on the meter type - useEffect(() => { - if (meterType) { - setFilteredRegisterList( - meterRegisterList.data?.filter( - (register: MeterRegister) => - register.meter_size == meterType.size && - register.brand.toLowerCase() == meterType.brand?.toLowerCase(), - ), - ); - } else { - setFilteredRegisterList(meterRegisterList.data); - } + const filteredRegisterList = useMemo(() => { + if (!meterType || meterTypeIsUnknown(meterType)) + return meterRegisterList.data ?? []; + + return (meterRegisterList.data ?? []).filter( + (r: MeterRegister) => + r.meter_size == meterType.size && + r.brand.toLowerCase() == meterType.brand?.toLowerCase(), + ); }, [meterType, meterRegisterList.data]); //Check if the selected register is in the filtered list, if not, set it to null @@ -103,3 +97,30 @@ export default function MeterRegisterSelect({ ); } + +const meterTypeLabel = ( + meterType?: MeterType | string | null, +): string | null => { + if (!meterType) return null; + + // If some code path passes a raw string + if (typeof meterType === "string") { + const s = meterType.trim(); + return s.length ? s : null; + } + + // Prefer description (often includes "Unknown") + const desc = meterType.description?.trim(); + if (desc) return desc; + + // Fall back to the same style you show in the select + const brand = meterType.brand?.trim() ?? ""; + const model = meterType.model?.trim() ?? ""; + const series = meterType.series?.trim() ?? ""; + + const label = [brand, series, model].filter(Boolean).join(" - ").trim(); + return label.length ? label : null; +}; + +const meterTypeIsUnknown = (meterType?: MeterType | string | null): boolean => + (meterTypeLabel(meterType) ?? "").toLowerCase().includes("unknown"); diff --git a/frontend/src/components/MeterSelection.tsx b/frontend/src/components/MeterSelection.tsx index 6a87e2f7..0d5136b8 100644 --- a/frontend/src/components/MeterSelection.tsx +++ b/frontend/src/components/MeterSelection.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { Autocomplete, TextField } from "@mui/material"; -import { useGetMeterList } from "../service/ApiServiceNew"; import { useDebounce } from "use-debounce"; -import { MeterListDTO } from "../interfaces"; -import { MeterStatusNames } from "../enums"; +import { useGetMeterList } from "@/service"; +import { MeterListDTO } from "@/interfaces"; +import { MeterStatusNames } from "@/enums"; interface MeterSelectionProps { selectedMeter: MeterListDTO | undefined; @@ -11,11 +11,11 @@ interface MeterSelectionProps { error?: boolean; } -export default function MeterSelection({ +export const MeterSelection = ({ selectedMeter, onMeterChange, error, -}: MeterSelectionProps) { +}: MeterSelectionProps) => { const [meterSearchQuery, setMeterSearchQuery] = useState(""); const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); @@ -80,4 +80,4 @@ export default function MeterSelection({ }} /> ); -} +}; diff --git a/frontend/src/components/MeterTypeSelect.tsx b/frontend/src/components/MeterTypeSelect.tsx index 05246ac1..d2be1e64 100644 --- a/frontend/src/components/MeterTypeSelect.tsx +++ b/frontend/src/components/MeterTypeSelect.tsx @@ -1,6 +1,6 @@ -import { useGetMeterTypeList } from "../service/ApiServiceNew"; import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; -import { MeterTypeLU } from "../interfaces"; +import { useGetMeterTypeList } from "@/service"; +import { MeterTypeLU } from "@/interfaces"; export default function MeterTypeSelect({ selectedMeterTypeID, diff --git a/frontend/src/components/ModalBackgroundBox.tsx b/frontend/src/components/ModalBackgroundBox.tsx index 23306cdf..23681499 100644 --- a/frontend/src/components/ModalBackgroundBox.tsx +++ b/frontend/src/components/ModalBackgroundBox.tsx @@ -13,14 +13,14 @@ export const ModalBackgroundBox: React.FC = ({ top: "50%", left: "50%", transform: "translate(-50%, -50%)", - bgcolor: "background.paper", // MUI theme-aware background - boxShadow: 24, // MUI’s shadow scale (number, not string) - borderRadius: 2, // uses theme.spacing(2) = 16px - p: 4, // shorthand padding (theme.spacing(4) = 32px) - width: "90%", // responsive width - maxWidth: 600, // cap width for large screens - maxHeight: "90vh", // keep it from overflowing viewport - overflowY: "auto", // scroll if content is tall + bgcolor: "background.paper", // MUI theme-aware background + boxShadow: 24, // MUI’s shadow scale (number, not string) + borderRadius: 2, // uses theme.spacing(2) = 16px + p: 4, // shorthand padding (theme.spacing(4) = 32px) + width: "90%", // responsive width + maxWidth: 600, // cap width for large screens + maxHeight: "90vh", // keep it from overflowing viewport + overflowY: "auto", // scroll if content is tall display: "flex", flexDirection: "column", ...sx, diff --git a/frontend/src/components/Modals/MonitoredWell/Create.tsx b/frontend/src/components/Modals/MonitoredWell/Create.tsx index 2cda03ab..5f0f217b 100644 --- a/frontend/src/components/Modals/MonitoredWell/Create.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Create.tsx @@ -1,58 +1,85 @@ +import { useEffect, useState } from "react"; import { - Modal, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, + Dialog, } from "@mui/material"; -import { useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { - NewWellMeasurement, - SecurityScope, -} from "../../../interfaces.js"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); + import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "../../../service/ApiServiceNew"; -import { ModalBackgroundBox } from "../../ModalBackgroundBox.js"; +import { NewWellMeasurement, SecurityScope } from "@/interfaces"; +import { useGetUserList } from "@/service"; +import { Save } from "@mui/icons-material"; -export function CreateModal({ - isNewMeasurementModalOpen, - handleCloseNewMeasurementModal, +export const CreateModal = ({ + open, + onClose, handleSubmitNewMeasurement, + title = "Create New Measurement", }: { - isNewMeasurementModalOpen: boolean; - handleCloseNewMeasurementModal: () => void; + open: boolean; + onClose: () => void; handleSubmitNewMeasurement: (newMeasurement: NewWellMeasurement) => void; -}) { + title?: string; +}) => { const authUser = useAuthUser(); + const userList = useGetUserList(); + const hasAdminScope = authUser() ?.user_role.security_scopes.map( (scope: SecurityScope) => scope.scope_string, ) .includes("admin"); - const userList = useGetUserList(); - const [value, setValue] = useState(null); - const [selectedUserID, setSelectedUserID] = useState(""); + const [valueRaw, setValueRaw] = useState(""); + const [selectedUserID, setSelectedUserID] = useState(null); const [date, setDate] = useState(dayjs.utc()); const [time, setTime] = useState(dayjs.utc()); - // Sends user entered information to the parent through callback + useEffect(() => { + if (!open) return; + + if (!hasAdminScope) { + const id = authUser()?.id; + setSelectedUserID(typeof id === "number" ? id : Number(id)); + } else { + setSelectedUserID(null); + } + }, [open, hasAdminScope]); + + const valueNum = valueRaw === "" ? NaN : Number(valueRaw); + const hasValue = Number.isFinite(valueNum); + + const canSave = + selectedUserID != null && + Number.isFinite(selectedUserID) && + selectedUserID > 0 && + date != null && + date.isValid() && + time != null && + time.isValid() && + hasValue; + function onMeasurementSubmitted() { - // default fallback: now + if (!canSave) return; + const selectedDate = date ?? dayjs(); const selectedTime = time ?? dayjs(); - // merge date + time into one object const combinedDateTime = selectedDate .hour(selectedTime.hour()) .minute(selectedTime.minute()) @@ -60,8 +87,8 @@ export function CreateModal({ handleSubmitNewMeasurement({ timestamp: combinedDateTime.toISOString(), - value: value as number, - submitting_user_id: selectedUserID as number, + value: valueNum, + submitting_user_id: selectedUserID, well_id: -1, // Set by parent }); } @@ -90,79 +117,97 @@ export function CreateModal({ ); - } else { - setSelectedUserID(authUser()?.id); - return null; } } return ( - - - - - Create New Measurement - - - - - - - - - - - - - setValue(event.target.value as unknown as number) - } - /> - - {title} + + + + - - - - - + Enter the measurement details below. Date and time default to the + current moment and can be adjusted if needed. + + + + + + + + + setValueRaw(e.target.value)} + error={valueRaw !== "" && !Number.isFinite(valueNum)} + helperText={ + valueRaw !== "" && !Number.isFinite(valueNum) + ? "Enter a valid number." + : " " + } + /> + + + + + + + + ); -} +}; diff --git a/frontend/src/components/Modals/MonitoredWell/Update.tsx b/frontend/src/components/Modals/MonitoredWell/Update.tsx index 4808043b..85b69032 100644 --- a/frontend/src/components/Modals/MonitoredWell/Update.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Update.tsx @@ -1,147 +1,178 @@ import { - Modal, + Dialog, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, } from "@mui/material"; -import { - PatchWellMeasurement, -} from "../../../interfaces.js"; +import { Save, Delete } from "@mui/icons-material"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); + import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "../../../service/ApiServiceNew"; -import { ModalBackgroundBox } from "./../../"; +import { useGetUserList } from "@/service"; +import { PatchWellMeasurement } from "@/interfaces"; export function UpdateModal({ - isMeasurementModalOpen, - handleCloseMeasurementModal, + open, + onClose, measurement, onUpdateMeasurement, onSubmitUpdate, onDeleteMeasurement, }: { - isMeasurementModalOpen: boolean; - handleCloseMeasurementModal: () => void; - measurement: PatchWellMeasurement; + open: boolean; + onClose: () => void; + measurement: Partial; onUpdateMeasurement: (value: Partial) => void; onSubmitUpdate: () => void; onDeleteMeasurement: () => void; }) { const userList = useGetUserList(); + const userIdNum = Number(measurement.submitting_user_id); + const ts = measurement.timestamp ? dayjs(measurement.timestamp as any) : null; + + const valueNum = measurement.value == null ? NaN : Number(measurement.value); + + const canSave = + Number.isFinite(userIdNum) && + userIdNum > 0 && + ts != null && + ts.isValid() && + Number.isFinite(valueNum); + return ( - - - - - Update Measurement - - - - User - - - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - slotProps={{ - textField: { size: "small", fullWidth: true, required: true }, - }} - /> - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null + + + Update Measurement + + + + + + Update the measurement details below. Adjust date/time as needed, + then click Update to save changes. + + + + User + + + + { + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null; + }} + slotProps={{ + textField: { size: "small", fullWidth: true, required: true }, + }} + /> + + { + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null; + }} + /> + + { + const rawValue: string = event.target.value; + const valueNum: number = rawValue === "" ? NaN : Number(rawValue); + + onUpdateMeasurement({ + value: valueNum, + }); + }} + /> + + + + + + + + ); } diff --git a/frontend/src/components/Modals/Notifications/Create.tsx b/frontend/src/components/Modals/Notifications/Create.tsx new file mode 100644 index 00000000..8862096d --- /dev/null +++ b/frontend/src/components/Modals/Notifications/Create.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Autocomplete, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + TextField, +} from "@mui/material"; +import { Save } from "@mui/icons-material"; +import { useForm } from "react-hook-form"; +import { ControlledUserSelect } from "@/components"; +import { + CreateNotificationPayload, + NotificationType, + User, + UserRole, +} from "@/interfaces"; +import { getRoleColor } from "@/utils"; + +const getRoleChipColor = (role?: string) => { + const color = getRoleColor(role); + return color === "inherit" ? "default" : color; +}; + +const formatNotificationTypeName = (value: string) => + value + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + +type FormValues = { + users: User[]; +}; + +export const CreateNotificationModal = ({ + open, + onClose, + users, + roles, + notificationTypes, + onSubmit, + loading, +}: { + open: boolean; + onClose: () => void; + users: User[]; + roles: UserRole[]; + notificationTypes: NotificationType[]; + onSubmit: (payload: CreateNotificationPayload) => void; + loading?: boolean; +}) => { + const activeUsers = useMemo( + () => users.filter((user) => !user.disabled), + [users], + ); + const { control, reset, watch } = useForm({ + defaultValues: { users: [] }, + }); + const selectedUsers = watch("users") ?? []; + + const [selectedRoles, setSelectedRoles] = useState([]); + const [selectedType, setSelectedType] = useState(null); + const [title, setTitle] = useState(""); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!open) return; + reset({ users: [] }); + setSelectedRoles([]); + setSelectedType(notificationTypes[0] ?? null); + setTitle(""); + setMessage(""); + }, [open, notificationTypes, reset]); + + const hasRecipients = selectedUsers.length > 0 || selectedRoles.length > 0; + const canSave = + hasRecipients && + !!selectedType && + title.trim().length > 0 && + message.trim().length > 0; + + const handleSubmit = () => { + if (!canSave || !selectedType) return; + + onSubmit({ + user_ids: selectedUsers.map((user) => user.id), + role_ids: selectedRoles.map((role) => role.id), + notification_type_id: selectedType.id, + title: title.trim(), + message: message.trim(), + }); + }; + + return ( + + + Create Notification + + + + + Select one or more roles or individual users, then enter the + notification details. + + setSelectedRoles(value)} + getOptionLabel={(option) => option.name} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + setSelectedType(value)} + getOptionLabel={(option) => formatNotificationTypeName(option.name)} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + setTitle(event.target.value)} + error={title.trim().length === 0} + helperText={title.trim().length === 0 ? "Title is required." : " "} + /> + setMessage(event.target.value)} + multiline + minRows={3} + error={message.trim().length === 0} + helperText={message.trim().length === 0 ? "Message is required." : " "} + /> + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/Notifications/index.ts b/frontend/src/components/Modals/Notifications/index.ts new file mode 100644 index 00000000..c65721e2 --- /dev/null +++ b/frontend/src/components/Modals/Notifications/index.ts @@ -0,0 +1 @@ +export * from "./Create"; diff --git a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx new file mode 100644 index 00000000..88283fed --- /dev/null +++ b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { DatePicker } from "@mui/x-date-pickers"; +import dayjs, { Dayjs } from "dayjs"; +import { Part } from "@/interfaces"; +import { IncreaseQuantityPayload } from "@/interfaces/IncreaseQuantityPayload"; +import { Save } from "@mui/icons-material"; + +export const IncreaseQuantityModal = ({ + open, + onClose, + parts, + defaultPartId, + onSubmit, + title = "Increase Part Quantity", + loading, +}: { + open: boolean; + onClose: () => void; + parts: Part[]; + defaultPartId?: number | string; + onSubmit: (payload: IncreaseQuantityPayload) => void; + title?: string; + loading?: boolean; +}) => { + const partsById = useMemo(() => { + const map = new Map(); + for (const p of parts) map.set(p.id, p); + return map; + }, [parts]); + + const [selectedPart, setSelectedPart] = useState(null); + const [increaseBy, setIncreaseBy] = useState("1"); + const [date, setDate] = useState(dayjs()); + const [note, setNote] = useState(""); + + const increaseByNum = Number(increaseBy); + + const partError = !selectedPart; + const qtyError = + increaseBy.trim().length === 0 || + Number.isNaN(increaseByNum) || + !Number.isFinite(increaseByNum) || + increaseByNum <= 0; + + // When opening, set defaults (today + optional part) + useEffect(() => { + if (!open) return; + + setDate(dayjs()); + setIncreaseBy("1"); + setNote(""); + + if (defaultPartId !== undefined) { + const p = partsById.get(defaultPartId) ?? null; + setSelectedPart(p); + } else { + setSelectedPart(null); + } + }, [open, defaultPartId, partsById]); + + const handleSubmit = () => { + if (!selectedPart || qtyError) return; + + onSubmit({ + part_id: selectedPart.id, + count: Math.trunc(increaseByNum), + date: date?.format("YYYY-MM-DD"), + note: note.trim().length ? note.trim() : undefined, + }); + }; + + return ( + + {title} + + + + + Select a part, enter how many to add, and confirm the date. + + + setSelectedPart(value)} + getOptionLabel={(option) => + option?.part_number + ? `${option.part_number} — ${option.description ?? ""}` + : (option?.description ?? "") + } + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + + setIncreaseBy(e.target.value)} + inputProps={{ min: 1, step: 1 }} + error={qtyError} + helperText={qtyError ? "Enter a number greater than 0." : " "} + /> + + setDate(newDate)} + disableFuture + slotProps={{ + textField: { + helperText: "Defaults to today.", + fullWidth: true, + size: "small", + }, + }} + /> + + setNote(e.target.value)} + placeholder="Optional note (e.g., received shipment, inventory correction)" + multiline + minRows={2} + maxRows={4} + /> + + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/Parts/index.ts b/frontend/src/components/Modals/Parts/index.ts new file mode 100644 index 00000000..fad3d59b --- /dev/null +++ b/frontend/src/components/Modals/Parts/index.ts @@ -0,0 +1 @@ +export * from "./IncreaseQuantity"; diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index 6208e47a..f6957aca 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -1,45 +1,50 @@ +import { useEffect, useState } from "react"; import { - Modal, + Dialog, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, FormControlLabel, Checkbox, } from "@mui/material"; -import { useState } from "react"; +import { RadioButtonUnchecked, TaskAlt, Save } from "@mui/icons-material"; +import { DatePicker, TimePicker } from "@mui/x-date-pickers"; import { useAuthUser } from "react-auth-kit"; +import { useQuery } from "react-query"; import { MonitoredWell, NewRegionMeasurement, SecurityScope, -} from "../../../interfaces.js"; +} from "@/interfaces"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { RadioButtonUnchecked, TaskAlt } from "@mui/icons-material"; -import { useGetUserList } from "../../../service/ApiServiceNew"; -import { useQuery } from "react-query"; -import { useFetchWithAuth } from "../../../hooks/useFetchWithAuth.js"; -import { ModalBackgroundBox } from "./../../"; + +import { useGetUserList } from "@/service"; +import { useFetchWithAuth } from "@/hooks"; export const CreateModal = ({ - region_id, //Used to filter wells - isNewMeasurementModalOpen, - handleCloseNewMeasurementModal, + region_id, + open, + onClose, handleSubmitNewMeasurement, + title = "Create New Measurement", }: { - region_id: number; //Used to filter wells - isNewMeasurementModalOpen: boolean; - handleCloseNewMeasurementModal: () => void; - handleSubmitNewMeasurement: (newMeasurement: NewRegionMeasurement) => void; + region_id: number; + open: boolean; + onClose: () => void; + handleSubmitNewMeasurement: (m: Partial) => void; + title?: string; }) => { const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -49,12 +54,13 @@ export const CreateModal = ({ .includes("admin"); const fetchWithAuth = useFetchWithAuth(); + const regionId = region_id; const { data: wells, isLoading: isLoadingWells } = useQuery< { items: MonitoredWell[] }, Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups", region_id], + queryKey: ["wells", "has_chloride_groups", regionId], queryFn: () => fetchWithAuth({ method: "GET", @@ -63,11 +69,11 @@ export const CreateModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, - chloride_group_id: region_id, + chloride_group_id: regionId, limit: 100, }, }), - enabled: isNewMeasurementModalOpen, + enabled: open && !!regionId, select: (res) => res.items, }); @@ -94,38 +100,44 @@ export const CreateModal = ({ region_id: 0, // Set by parent well_id: selectedWellID as number, timestamp: combinedDateTime.toISOString(), - value: value as number, + value: notSampled ? null : value, submitting_user_id: selectedUserID as number, }); } - const UserSelection = () => { - if (hasAdminScope) { - return ( - - User - - - ); - } else { - setSelectedUserID(authUser()?.id); - return null; + useEffect(() => { + if (!open) return; + + if (!hasAdminScope) { + const id = authUser()?.id; + if (id != null) setSelectedUserID(id); } + }, [open, hasAdminScope, authUser]); + + const UserSelection = () => { + if (!hasAdminScope) return null; + + return ( + + User + + + ); }; const WellSelection = ({ region_id }: { region_id: number }) => { @@ -138,7 +150,9 @@ export const CreateModal = ({ label="Well" > {wells - ?.filter((well: MonitoredWell) => well.chloride_group_id === region_id) + ?.filter( + (well: MonitoredWell) => well.chloride_group_id === region_id, + ) .map((well: MonitoredWell) => ( {well.ra_number} @@ -154,101 +168,124 @@ export const CreateModal = ({ ); }; + const userOk = + Number.isFinite(Number(selectedUserID)) && Number(selectedUserID) > 0; + const wellOk = + Number.isFinite(Number(selectedWellID)) && Number(selectedWellID) > 0; + const dateOk = date != null && dayjs(date).isValid(); + const timeOk = time != null && dayjs(time).isValid(); + const hasValue = value !== null && Number.isFinite(value); + + const canSave = + userOk && wellOk && dateOk && timeOk && (notSampled || hasValue); + return ( - - - - - Create New Measurement - - - - - - - - - - - - } - checkedIcon={} - checked={notSampled} - onChange={(e) => { - const checked = e.target.checked; - setNotSampled(checked) - - if (checked) { - setValue(null); - } - }} - /> - } - label="Well was visited but NOT SAMPLED" - labelPlacement="end" - /> - - - { - const newValue = event.target.value; - setValue(newValue === "" ? null : Number(newValue)); - }} - /> - - - - - {title} + + + + - - - - - + Enter the measurement details below. Date and time default to the + current moment and can be adjusted if needed. + + + + + + + + + } + checkedIcon={} + checked={notSampled} + onChange={(e) => { + const checked = e.target.checked; + setNotSampled(checked); + + if (checked) { + setValue(null); + } + }} + /> + } + label="Well was visited but NOT SAMPLED" + labelPlacement="end" + /> + + { + const newValue = event.target.value; + setValue(newValue === "" ? null : Number(newValue)); + }} + /> + + + + + + + + + + ); }; diff --git a/frontend/src/components/Modals/Region/Update.tsx b/frontend/src/components/Modals/Region/Update.tsx index 1c5e4dcb..8fc42dd9 100644 --- a/frontend/src/components/Modals/Region/Update.tsx +++ b/frontend/src/components/Modals/Region/Update.tsx @@ -1,56 +1,64 @@ import { useEffect, useState } from "react"; import { - Modal, + Dialog, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, FormControlLabel, Checkbox, } from "@mui/material"; -import { - MonitoredWell, - PatchRegionMeasurement, -} from "../../../interfaces.js"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import dayjs from "dayjs"; dayjs.extend(utc); dayjs.extend(timezone); + import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { RadioButtonUnchecked, TaskAlt } from "@mui/icons-material"; -import { useGetUserList } from "../../../service/ApiServiceNew"; +import { + RadioButtonUnchecked, + TaskAlt, + Delete, + Save, +} from "@mui/icons-material"; +import { useGetUserList } from "@/service"; import { useQuery } from "react-query"; -import { useFetchWithAuth } from "../../../hooks/useFetchWithAuth.js"; -import { ModalBackgroundBox } from "./../../"; - +import { useFetchWithAuth } from "@/hooks"; +import { MonitoredWell, PatchRegionMeasurement } from "@/interfaces"; export const UpdateModal = ({ - region_id, //Used to filter wells - isMeasurementModalOpen, - handleCloseMeasurementModal, + region_id, + open, + onClose, measurement, onUpdateMeasurement, onSubmitUpdate, onDeleteMeasurement, + title = "Update Measurement", }: { - region_id: number; //Used to filter wells - isMeasurementModalOpen: boolean; - handleCloseMeasurementModal: () => void; - measurement: PatchRegionMeasurement; + region_id?: number; + open: boolean; + onClose: () => void; + measurement: Partial; onUpdateMeasurement: (value: Partial) => void; onSubmitUpdate: () => void; onDeleteMeasurement: () => void; + title?: string; }) => { + const regionId = region_id; + const userList = useGetUserList(); const fetchWithAuth = useFetchWithAuth(); const [notSampled, setNotSampled] = useState( - measurement.value === undefined || measurement.value === null + measurement.value === undefined || measurement.value === null, ); const [previousValue, setPreviousValue] = useState(null); @@ -59,7 +67,7 @@ export const UpdateModal = ({ Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups", region_id], + queryKey: ["wells", "has_chloride_groups", regionId], queryFn: () => fetchWithAuth({ method: "GET", @@ -68,11 +76,11 @@ export const UpdateModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, - chloride_group_id: region_id, + chloride_group_id: regionId, limit: 100, }, }), - enabled: isMeasurementModalOpen, + enabled: open && !!regionId, select: (res) => res.items, }); @@ -95,161 +103,186 @@ export const UpdateModal = ({ setNotSampled(measurement.value == null); }, [measurement.value]); + const userIdNum = Number(measurement.submitting_user_id); + const ts = measurement.timestamp ? dayjs(measurement.timestamp as any) : null; + + const valueNum = measurement.value == null ? NaN : Number(measurement.value); + + const hasValue = valueNum !== null && Number.isFinite(valueNum); + + const canSave = + Number.isFinite(userIdNum) && + userIdNum > 0 && + ts != null && + ts.isValid() && + (notSampled || hasValue); + return ( - - - - - Update Measurement - - - - User - - - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - slotProps={{ - textField: { size: "small", fullWidth: true, required: true }, - }} - /> - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - /> - - - } - checkedIcon={} - checked={notSampled} - onChange={(e) => handleToggleNotSampled(e.target.checked)} - /> - } - label="Well was visited but NOT SAMPLED" - labelPlacement="end" - /> - - - + {title} + + + + + Update the measurement details below. Adjust date/time as needed, + then click Update to save changes. + + + + User + - onUpdateMeasurement({ - well_id: event.target.value, - }) - } - label="Well" - > - {wells - ?.filter((well: MonitoredWell) => well.chloride_group_id === region_id) - .map((well: MonitoredWell) => ( - - {well.ra_number} - - ))} - {isLoadingWells && ( - - )} - - - - - - + + + + ); -} +}; diff --git a/frontend/src/components/Modals/WorkOrders/Create.tsx b/frontend/src/components/Modals/WorkOrders/Create.tsx new file mode 100644 index 00000000..4af34cef --- /dev/null +++ b/frontend/src/components/Modals/WorkOrders/Create.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + TextField, +} from "@mui/material"; +import { Save } from "@mui/icons-material"; +import { MeterListDTO, NewWorkOrder } from "@/interfaces"; +import { MeterSelection } from "@/components"; + +export const Create = ({ + open, + onClose, + submitNewWorkOrder, +}: { + open: boolean; + onClose: () => void; + submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; +}) => { + const [workOrderTitle, setWorkOrderTitle] = useState(""); + const [workOrderMeter, setWorkOrderMeter] = useState< + MeterListDTO | undefined + >(); + const [meterSelectionError, setMeterSelectionError] = + useState(false); + const [titleError, setTitleError] = useState(false); + + function handleSubmit() { + if (!workOrderMeter) { + setMeterSelectionError(true); + return; + } + if (!workOrderTitle) { + setTitleError(true); + return; + } + + //If both fields are filled, submit the work order + //Create a new work order object + const newWorkOrder: NewWorkOrder = { + date_created: new Date(), + meter_id: workOrderMeter.id, + title: workOrderTitle, + }; + submitNewWorkOrder(newWorkOrder); + onClose(); + + //Reset the form + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + } + + const handleCancel = () => { + onClose(); + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + }; + + const canSave = Boolean(workOrderMeter) && workOrderTitle.trim().length > 0; + + return ( + + Create a New Work Order + + + + Select a meter and enter a title to create the work order. You can + update the remaining details after it’s created. + + + setWorkOrderTitle(event.target.value)} + error={titleError} + helperText={titleError ? "Title cannot be empty" : ""} + /> + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/WorkOrders/index.ts b/frontend/src/components/Modals/WorkOrders/index.ts new file mode 100644 index 00000000..c65721e2 --- /dev/null +++ b/frontend/src/components/Modals/WorkOrders/index.ts @@ -0,0 +1 @@ +export * from "./Create"; diff --git a/frontend/src/components/Modals/index.ts b/frontend/src/components/Modals/index.ts index 0e9fb69b..222f727c 100644 --- a/frontend/src/components/Modals/index.ts +++ b/frontend/src/components/Modals/index.ts @@ -1 +1,3 @@ -export * from './Region' +export * from "./Notifications"; +export * from "./Region"; +export * from "./Parts"; diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index f6541404..325b4ec3 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -1,7 +1,14 @@ -import { SvgIconProps, Badge, ListItem, ListItemButton, ListItemIcon, ListItemText } from "@mui/material"; -import TableViewIcon from "@mui/icons-material/TableView"; -import { Link, type LinkProps } from "react-router-dom"; -import { useIsActiveRoute } from "../hooks"; +import { + SvgIconProps, + Badge, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { TableView } from "@mui/icons-material"; +import { Link } from "@tanstack/react-router"; +import { useIsActiveRoute } from "@/hooks"; export const NavLink = ({ disabled = false, @@ -12,7 +19,7 @@ export const NavLink = ({ subItem = false, }: { disabled?: boolean; - route: LinkProps["to"]; + route: string; label: string; icon?: React.ComponentType; badgeContent?: number; @@ -46,7 +53,7 @@ export const NavLink = ({ ) : ( - + )} void; + dragMode?: PlotDragMode; + onToggleDragMode?: () => void; +}) => { + const [contextMenu, setContextMenu] = useState( + null, + ); + + const handleContextMenu = (event: MouseEvent) => { + event.preventDefault(); + setContextMenu({ + mouseX: event.clientX + 2, + mouseY: event.clientY - 6, + }); + }; + + const handleClose = () => { + setContextMenu(null); + }; + + const handleResetAxes = () => { + handleClose(); + onResetAxes(); + }; + + const handleToggleDragMode = () => { + handleClose(); + onToggleDragMode?.(); + }; + + return ( + + {children} + + {dragMode && onToggleDragMode && ( + + Switch to {dragMode === "pan" ? "zoom" : "pan"} + + )} + + Reset axes + + + + ); +}; diff --git a/frontend/src/components/RHControlled/ControlledActivitySelect.tsx b/frontend/src/components/RHControlled/ControlledActivitySelect.tsx index f74c1b90..906d01ca 100644 --- a/frontend/src/components/RHControlled/ControlledActivitySelect.tsx +++ b/frontend/src/components/RHControlled/ControlledActivitySelect.tsx @@ -1,12 +1,12 @@ -import { useGetActivityTypeList } from "../../service/ApiServiceNew"; -import { ActivityTypeLU } from "../../interfaces"; +import { useGetActivityTypeList } from "@/service"; +import { ActivityTypeLU } from "@/interfaces"; import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledActivitySelect({ +export const ControlledActivitySelect = ({ name, control, ...childProps -}: any) { +}: any) => { const activityTypeList = useGetActivityTypeList(); return ( @@ -21,4 +21,4 @@ export default function ControlledActivitySelect({ value={activityTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx index d474020e..ca0424d3 100644 --- a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx +++ b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx @@ -8,7 +8,7 @@ const disabledInputStyle = { cursor: "default", }; -export default function ControlledAutocomplete({ +export const ControlledAutocomplete = ({ control, name, options = [], @@ -17,37 +17,35 @@ export default function ControlledAutocomplete({ isOptionEqualToValue, multiple = false, ...childProps -}: any) { - return ( - { - const { value, onChange, ...restField } = field; +}: any) => ( + { + const { value, onChange, ...restField } = field; - const safeValue = multiple - ? Array.isArray(value) - ? value - : [] - : value ?? null; + const safeValue = multiple + ? Array.isArray(value) + ? value + : [] + : (value ?? null); - return ( - onChange(newValue)} - sx={disabledInputStyle} - {...childProps} - /> - ); - }} - /> - ); -} + return ( + onChange(newValue)} + sx={disabledInputStyle} + {...childProps} + /> + ); + }} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledCheckbox.tsx b/frontend/src/components/RHControlled/ControlledCheckbox.tsx index da701cca..4d85b486 100644 --- a/frontend/src/components/RHControlled/ControlledCheckbox.tsx +++ b/frontend/src/components/RHControlled/ControlledCheckbox.tsx @@ -8,32 +8,25 @@ const disabledInputStyle = { cursor: "default", }; -export default function ControlledCheckbox({ - name, - control, - ...childProps -}: any) { - return ( - { - //console.log(field) - return ( - - } - {...childProps} - /> - ); - }} - /> - ); -} +export const ControlledCheckbox = ({ name, control, ...childProps }: any) => ( + { + return ( + + } + {...childProps} + /> + ); + }} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledDMS.tsx b/frontend/src/components/RHControlled/ControlledDMS.tsx index 79c4afa9..05430e50 100644 --- a/frontend/src/components/RHControlled/ControlledDMS.tsx +++ b/frontend/src/components/RHControlled/ControlledDMS.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState, forwardRef } from "react"; import { TextField } from "@mui/material"; import { Controller } from "react-hook-form"; -import { GCSdimension } from "../../enums"; import { PatternFormat, PatternFormatProps } from "react-number-format"; +import { GCSdimension } from "@/enums"; interface DMSInputProps { dimension_type: GCSdimension; @@ -16,7 +16,7 @@ interface CustomProps { name: string; } -const DMSFormatCustom = React.forwardRef( +const DMSFormatCustom = forwardRef( function PatternFormatCustom(props, ref) { const { onChange, ...other } = props; @@ -115,6 +115,8 @@ function DMSInput({ dimension_type, value, onChange }: DMSInputProps) { value={dms_string} onChange={handleUpdate} onBlur={handleBlur} + fullWidth + size="small" InputProps={{ inputComponent: DMSFormatCustom as any, }} @@ -122,18 +124,16 @@ function DMSInput({ dimension_type, value, onChange }: DMSInputProps) { ); } -export default function ControlledDMS({ name, control, ...childProps }: any) { - return ( - ( - field.onChange(newValue)} - /> - )} - /> - ); -} +export const ControlledDMS = ({ name, control, ...childProps }: any) => ( + ( + field.onChange(newValue)} + /> + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledDatepicker.tsx b/frontend/src/components/RHControlled/ControlledDatepicker.tsx index 78055d85..1b0917bf 100644 --- a/frontend/src/components/RHControlled/ControlledDatepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledDatepicker.tsx @@ -1,23 +1,21 @@ import { DatePicker } from "@mui/x-date-pickers"; import { Controller } from "react-hook-form"; -export default function ControlledDatepicker({ +export const ControlledDatepicker = ({ name, control, size = "small", ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +}: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx b/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx index 696d6928..9fea17b5 100644 --- a/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx @@ -1,23 +1,22 @@ -import MeterRegisterSelect from "../MeterRegisterSelect"; import { Controller } from "react-hook-form"; -export default function ControlledMeterRegisterSelect({ +import MeterRegisterSelect from "../MeterRegisterSelect"; + +export const ControlledMeterRegisterSelect = ({ control, name, ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +}: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledMeterSelection.tsx b/frontend/src/components/RHControlled/ControlledMeterSelection.tsx index 42b34342..c21df5cb 100644 --- a/frontend/src/components/RHControlled/ControlledMeterSelection.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterSelection.tsx @@ -1,16 +1,18 @@ import { useState } from "react"; import { TextField } from "@mui/material"; import { useDebounce } from "use-debounce"; -import { useGetMeterList } from "../../service/ApiServiceNew"; -import { MeterListDTO } from "../../interfaces"; -import ControlledAutocomplete from "./ControlledAutocomplete"; -import { MeterStatusNames } from "../../enums"; -export default function ControlledMeterSelection({ +import { useGetMeterList } from "@/service"; +import { MeterListDTO } from "@/interfaces"; +import { MeterStatusNames } from "@/enums"; + +import { ControlledAutocomplete } from "./ControlledAutocomplete"; + +export const ControlledMeterSelection = ({ name, control, ...childProps -}: any) { +}: any) => { const [meterSearchQuery, setMeterSearchQuery] = useState(""); const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); @@ -63,4 +65,4 @@ export default function ControlledMeterSelection({ }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx b/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx index 838fd6d6..0872403e 100644 --- a/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx @@ -1,12 +1,13 @@ -import { useGetMeterStatusTypeList } from "../../service/ApiServiceNew"; -import { MeterStatus } from "../../interfaces"; +import { useGetMeterStatusTypeList } from "@/service"; +import { MeterStatus } from "@/interfaces"; + import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledMeterStatusTypeSelect({ +export const ControlledMeterStatusTypeSelect = ({ name, control, ...childProps -}: any) { +}: any) => { const statusTypeList = useGetMeterStatusTypeList(); return ( @@ -21,4 +22,4 @@ export default function ControlledMeterStatusTypeSelect({ value={statusTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx b/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx index 688eec03..7d1821f5 100644 --- a/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx @@ -1,12 +1,13 @@ -import { useGetMeterTypeList } from "../../service/ApiServiceNew"; -import { MeterTypeLU } from "../../interfaces"; +import { useGetMeterTypeList } from "@/service"; +import { MeterTypeLU } from "@/interfaces"; + import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledMeterTypeSelect({ +export const ControlledMeterTypeSelect = ({ name, control, ...childProps -}: any) { +}: any) => { const meterTypeList = useGetMeterTypeList(); return ( @@ -21,4 +22,4 @@ export default function ControlledMeterTypeSelect({ value={meterTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx b/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx index bbe8b0aa..0d4e6794 100644 --- a/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx @@ -1,12 +1,13 @@ -import { useGetPartTypeList } from "../../service/ApiServiceNew"; -import { PartTypeLU } from "../../interfaces"; +import { useGetPartTypeList } from "@/service"; +import { PartTypeLU } from "@/interfaces"; + import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledPartTypeSelect({ +export const ControlledPartTypeSelect = ({ name, control, ...childProps -}: any) { +}: any) => { const partTypeList = useGetPartTypeList(); return ( @@ -21,4 +22,4 @@ export default function ControlledPartTypeSelect({ value={partTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledSelect.tsx b/frontend/src/components/RHControlled/ControlledSelect.tsx index 696cf9f8..70bae13a 100644 --- a/frontend/src/components/RHControlled/ControlledSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledSelect.tsx @@ -20,6 +20,9 @@ export function ControlledSelect({ control={control} render={({ field }) => { const isMultiple = multiple; + const options = Array.isArray(childProps.options) + ? childProps.options + : []; // Normalize value for multi/single mode const value = isMultiple @@ -28,13 +31,15 @@ export function ControlledSelect({ const handleChange = (event: any) => { if (isMultiple) { - const selectedIds = event.target.value; - const selectedOptions = childProps.options.filter((opt: any) => + const selectedIds = Array.isArray(event.target.value) + ? event.target.value + : []; + const selectedOptions = options.filter((opt: any) => selectedIds.includes(opt.id), ); field.onChange(selectedOptions); } else { - const selectedOption = childProps.options.find( + const selectedOption = options.find( (opt: any) => opt.id === event.target.value, ); field.onChange(selectedOption); @@ -57,18 +62,18 @@ export function ControlledSelect({ label={childProps.label} renderValue={(selected: any) => isMultiple - ? childProps.options + ? options .filter((opt: any) => selected.includes(opt.id)) .map((opt: any) => childProps.getOptionLabel(opt)) .join(", ") : childProps.getOptionLabel( - childProps.options.find( + options.find( (opt: any) => opt.id === selected, ) ?? {}, ) } > - {childProps.options.map((option: any) => ( + {options.map((option: any) => ( {childProps.getOptionLabel(option)} @@ -93,6 +98,8 @@ export function ControlledSelectNonObject({ name, ...childProps }: any) { + const options = Array.isArray(childProps.options) ? childProps.options : []; + return ( - {childProps.options.map((option: any) => ( + {options.map((option: any) => ( {childProps.getOptionLabel(option)} diff --git a/frontend/src/components/RHControlled/ControlledTextbox.tsx b/frontend/src/components/RHControlled/ControlledTextbox.tsx index a7dd467d..6c1ce009 100644 --- a/frontend/src/components/RHControlled/ControlledTextbox.tsx +++ b/frontend/src/components/RHControlled/ControlledTextbox.tsx @@ -8,27 +8,21 @@ const disabledInputStyle = { cursor: "default", }; -export default function ControlledTextbox({ - name, - control, - ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +export const ControlledTextbox = ({ name, control, ...childProps }: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledTimepicker.tsx b/frontend/src/components/RHControlled/ControlledTimepicker.tsx index 53f2cd85..b8bd5323 100644 --- a/frontend/src/components/RHControlled/ControlledTimepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledTimepicker.tsx @@ -1,28 +1,22 @@ import { TimePicker } from "@mui/x-date-pickers"; import { Controller } from "react-hook-form"; -export default function ControlledTimepicker({ - name, - control, - ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +export const ControlledTimepicker = ({ name, control, ...childProps }: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledUserSelect.tsx b/frontend/src/components/RHControlled/ControlledUserSelect.tsx index d76b352f..626d78bb 100644 --- a/frontend/src/components/RHControlled/ControlledUserSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledUserSelect.tsx @@ -1,39 +1,206 @@ -import { useState } from "react"; -import { ControlledSelect } from "./ControlledSelect"; -import { User } from "../../interfaces"; -import { useGetUserList } from "../../service/ApiServiceNew"; +import { useEffect, useMemo, useState } from "react"; import { useAuthUser } from "react-auth-kit"; +import { Autocomplete, Box, Chip, Stack, TextField } from "@mui/material"; +import { + Control, + Controller, + FieldValues, + Path, + PathValue, + UseControllerProps, + UseFormSetValue, +} from "react-hook-form"; +import { User } from "@/interfaces"; +import { useGetUserList } from "@/service"; +import { UserAvatar } from "@/components/UserAvatar"; +import { + getRoleLabel, + sortUsersByRoleThenName, +} from "@/utils/UserRoleGrouping"; -export default function ControlledUserSelect({ +const getAvatarRole = (user: User | null | undefined) => + user ? getRoleLabel(user) : undefined; + +const isUserLike = (value: unknown): value is User => { + if (typeof value !== "object" || value === null || !("id" in value)) { + return false; + } + + return typeof value.id === "number" && Number.isFinite(value.id); +}; + +const getUserId = (value: unknown): number | undefined => + isUserLike(value) ? value.id : undefined; + +type ControlledUserSelectProps = { + name: Path; + control: Control; + hideAndSelectCurrentUser?: boolean; + setValue?: UseFormSetValue | null; + options?: User[]; + label?: string; + error?: string; + helperText?: string; + disabled?: boolean; + sx?: object; + multiple?: boolean; +}; + +export const ControlledUserSelect = ({ name, control, hideAndSelectCurrentUser = false, setValue = null, ...childProps -}: any) { +}: ControlledUserSelectProps) => { const [isCurrentUserSet, setIsCurrentUserSet] = useState(false); + const currentUser = useAuthUser(); + const userList = useGetUserList(); + const providedOptions = Array.isArray(childProps.options) + ? childProps.options + : undefined; + const users = useMemo( + () => sortUsersByRoleThenName(providedOptions ?? userList.data ?? []), + [providedOptions, userList.data], + ); + + useEffect(() => { + if (!hideAndSelectCurrentUser || isCurrentUserSet || !setValue) { + return; + } + + const authenticatedUser = currentUser(); + if (!authenticatedUser) { + return; + } + + setValue(name, authenticatedUser as PathValue>); + setIsCurrentUserSet(true); + }, [currentUser, hideAndSelectCurrentUser, isCurrentUserSet, name, setValue]); if (!hideAndSelectCurrentUser) { - const userList = useGetUserList(); + const { + label = "User", + error, + helperText, + disabled, + sx, + multiple = false, + ...autocompleteProps + } = childProps; return ( - user.full_name} - label="User" - disabled={userList.isLoading} - {...childProps} - value={userList.isLoading ? "Loading..." : childProps.value} + control={control} + defaultValue={null as UseControllerProps["defaultValue"]} + render={({ field }) => ( + (() => { + const selectedUsers: User[] = multiple + ? Array.isArray(field.value) + ? field.value + .map((value: unknown) => { + const valueId = getUserId(value); + return users.find((user) => user.id === valueId); + }) + .filter(Boolean) + : [] + : []; + const fieldValueId = getUserId(field.value); + const fieldValue = isUserLike(field.value) + ? (field.value as User) + : null; + const selectedUser: User | null = + !multiple + ? users.find((user) => user.id === fieldValueId) ?? + fieldValue ?? + null + : null; + + return ( + + {...autocompleteProps} + size="small" + multiple={multiple} + options={users} + groupBy={(user: User) => getRoleLabel(user)} + getOptionLabel={(user: User) => user?.full_name ?? ""} + isOptionEqualToValue={(option: User, value: User) => + option.id === value.id + } + value={multiple ? selectedUsers : selectedUser} + onChange={(_, newValue) => field.onChange(newValue)} + loading={userList.isLoading} + disabled={ + disabled ?? (providedOptions ? false : userList.isLoading) + } + sx={sx} + renderOption={(props, option) => ( + + + + {option.full_name} + + + )} + renderTags={(selected: readonly User[], getTagProps) => + selected.map((option, index) => ( + + } + {...getTagProps({ index })} + /> + )) + } + renderInput={(params) => { + const { InputProps, ...rest } = params; + const startAdornment = !multiple && selectedUser ? ( + <> + + {InputProps.startAdornment} + + ) : InputProps.startAdornment; + + return ( + + ); + }} + /> + ); + })() + )} /> ); } else { - if (!isCurrentUserSet) { - const currentUser = useAuthUser(); - setValue(name, currentUser()); - setIsCurrentUserSet(true); - } return null; } -} +}; diff --git a/frontend/src/components/RHControlled/ControlledWellSelection.tsx b/frontend/src/components/RHControlled/ControlledWellSelection.tsx index cb160284..7250ab34 100644 --- a/frontend/src/components/RHControlled/ControlledWellSelection.tsx +++ b/frontend/src/components/RHControlled/ControlledWellSelection.tsx @@ -1,15 +1,16 @@ import { useState } from "react"; import { TextField } from "@mui/material"; import { useDebounce } from "use-debounce"; -import { useGetWells } from "../../service/ApiServiceNew"; -import { Well } from "../../interfaces"; -import ControlledAutocomplete from "./ControlledAutocomplete"; +import { useGetWells } from "@/service"; +import { Well } from "@/interfaces"; -export default function ControlledWellSelection({ +import { ControlledAutocomplete } from "./ControlledAutocomplete"; + +export const ControlledWellSelection = ({ name, control, ...childProps -}: any) { +}: any) => { const [wellSearchQuery, setWellSearchQuery] = useState(""); const [wellSearchQueryDebounced] = useDebounce(wellSearchQuery, 250); @@ -45,4 +46,4 @@ export default function ControlledWellSelection({ }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/NSPChipSelect.tsx b/frontend/src/components/RHControlled/NSPChipSelect.tsx index d0fcdd06..10bba9ba 100644 --- a/frontend/src/components/RHControlled/NSPChipSelect.tsx +++ b/frontend/src/components/RHControlled/NSPChipSelect.tsx @@ -1,11 +1,12 @@ -import ChipSelect from "../ChipSelect"; -import { NoteTypeLU, ServiceTypeLU, PartTypeLU } from "../../interfaces"; +import { Controller } from "react-hook-form"; +import { NoteTypeLU, ServiceTypeLU, PartTypeLU } from "@/interfaces"; import { useGetNoteTypes, useGetServiceTypes, useGetPartTypeList, -} from "../../service/ApiServiceNew"; -import { Controller } from "react-hook-form"; +} from "@/service"; + +import ChipSelect from "../ChipSelect"; type SelectType = "Notes" | "Services" | "Parts"; diff --git a/frontend/src/components/RHControlled/NotesChipSelect.tsx b/frontend/src/components/RHControlled/NotesChipSelect.tsx index b4bf883a..883967e5 100644 --- a/frontend/src/components/RHControlled/NotesChipSelect.tsx +++ b/frontend/src/components/RHControlled/NotesChipSelect.tsx @@ -1,9 +1,11 @@ -import ChipSelect from "../ChipSelect"; -import { NoteTypeLU } from "../../interfaces"; -import { useGetNoteTypes } from "../../service/ApiServiceNew"; import { Controller } from "react-hook-form"; -export default function NotesChipSelect({ name, control }: any) { +import { NoteTypeLU } from "@/interfaces"; +import { useGetNoteTypes } from "@/service"; + +import ChipSelect from "../ChipSelect"; + +export const NotesChipSelect = ({ name, control }: any) => { const notesList = useGetNoteTypes(); return ( @@ -44,4 +46,4 @@ export default function NotesChipSelect({ name, control }: any) { }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/PartsChipSelect.tsx b/frontend/src/components/RHControlled/PartsChipSelect.tsx index b7cccb61..9a9751f6 100644 --- a/frontend/src/components/RHControlled/PartsChipSelect.tsx +++ b/frontend/src/components/RHControlled/PartsChipSelect.tsx @@ -1,9 +1,11 @@ -import ChipSelect from "../ChipSelect"; -import { Part } from "../../interfaces"; -import { useGetMeterPartsList } from "../../service/ApiServiceNew"; import { Controller } from "react-hook-form"; -export default function PartsChipSelect({ name, control, meterid }: any) { +import { Part } from "@/interfaces"; +import { useGetMeterPartsList } from "@/service"; + +import ChipSelect from "../ChipSelect"; + +export const PartsChipSelect = ({ name, control, meterid }: any) => { const partsList = useGetMeterPartsList({ meter_id: meterid }); return ( @@ -42,4 +44,4 @@ export default function PartsChipSelect({ name, control, meterid }: any) { }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ServicesChipSelect.tsx b/frontend/src/components/RHControlled/ServicesChipSelect.tsx index 4a970e19..c3010108 100644 --- a/frontend/src/components/RHControlled/ServicesChipSelect.tsx +++ b/frontend/src/components/RHControlled/ServicesChipSelect.tsx @@ -1,9 +1,11 @@ -import ChipSelect from "../ChipSelect"; -import { ServiceTypeLU } from "../../interfaces"; -import { useGetServiceTypes } from "../../service/ApiServiceNew"; import { Controller } from "react-hook-form"; -export default function ServicesChipSelect({ name, control }: any) { +import { ServiceTypeLU } from "@/interfaces"; +import { useGetServiceTypes } from "@/service"; + +import ChipSelect from "../ChipSelect"; + +export const ServicesChipSelect = ({ name, control }: any) => { const servicesList = useGetServiceTypes(); return ( @@ -46,4 +48,4 @@ export default function ServicesChipSelect({ name, control }: any) { }} /> ); -} +}; diff --git a/frontend/src/components/ReportBreadcrumbTitle.tsx b/frontend/src/components/ReportBreadcrumbTitle.tsx new file mode 100644 index 00000000..58e4b14c --- /dev/null +++ b/frontend/src/components/ReportBreadcrumbTitle.tsx @@ -0,0 +1,56 @@ +import AssessmentOutlinedIcon from "@mui/icons-material/AssessmentOutlined"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import { Box, Breadcrumbs, Link as MuiLink, Typography } from "@mui/material"; +import { Link as RouterLink } from "@tanstack/react-router"; + +export const ReportBreadcrumbTitle = ({ current }: { current: string }) => { + return ( + } + sx={{ + color: "inherit", + "& .MuiBreadcrumbs-ol": { + alignItems: "center", + }, + "& .MuiBreadcrumbs-separator": { + display: "inline-flex", + alignItems: "center", + color: "rgba(255, 255, 255, 0.72)", + mx: 1, + }, + }} + > + + + Reports + + + {current} + + + ); +}; diff --git a/frontend/src/components/ReportsNavItem.tsx b/frontend/src/components/ReportsNavItem.tsx index 2de81665..7b899b81 100644 --- a/frontend/src/components/ReportsNavItem.tsx +++ b/frontend/src/components/ReportsNavItem.tsx @@ -5,15 +5,17 @@ import { ListItemIcon, ListItemText, } from "@mui/material"; -import { - Assessment, - ExpandLess, - ExpandMore, -} from "@mui/icons-material"; -import { useNavigate } from "react-router-dom"; -import { useIsActiveRoute } from "../hooks"; +import { Assessment, ExpandLess, ExpandMore } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { useIsActiveRoute } from "@/hooks"; -export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Dispatch> }) { +export function ReportsNavItem({ + open, + setOpen, +}: { + open: boolean; + setOpen: Dispatch>; +}) { const navigate = useNavigate(); const [clickTimer, setClickTimer] = useState(null); const isActive = useIsActiveRoute("/reports"); @@ -37,7 +39,7 @@ export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Disp } e.stopPropagation(); setOpen(false); - navigate("/reports"); + navigate({ to: "/reports", search: {} }); }; return ( @@ -69,4 +71,3 @@ export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Disp ); } - diff --git a/frontend/src/components/ResizableSplitPanels.tsx b/frontend/src/components/ResizableSplitPanels.tsx new file mode 100644 index 00000000..35554b8b --- /dev/null +++ b/frontend/src/components/ResizableSplitPanels.tsx @@ -0,0 +1,153 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { Box, useMediaQuery, useTheme } from "@mui/material"; +import { alpha } from "@mui/material/styles"; + +type ResizableSplitPanelsProps = { + left: ReactNode; + right: ReactNode; + leftWidth?: number; + defaultLeftWidth?: number; + minLeftWidth?: number; + minRightWidth?: number; + desktopBreakpoint?: "sm" | "md" | "lg" | "xl"; + onLeftWidthChange?: (leftWidth: number) => void; +}; + +export const ResizableSplitPanels = ({ + left, + right, + leftWidth: controlledLeftWidth, + defaultLeftWidth = 58, + minLeftWidth = 35, + minRightWidth = 28, + desktopBreakpoint = "lg", + onLeftWidthChange, +}: ResizableSplitPanelsProps) => { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up(desktopBreakpoint)); + const containerRef = useRef(null); + const resizeStateRef = useRef<{ + startX: number; + startLeftWidth: number; + containerWidth: number; + } | null>(null); + const [uncontrolledLeftWidth, setUncontrolledLeftWidth] = + useState(defaultLeftWidth); + const leftWidth = controlledLeftWidth ?? uncontrolledLeftWidth; + + useEffect(() => { + if (controlledLeftWidth === undefined) { + return; + } + + setUncontrolledLeftWidth(controlledLeftWidth); + }, [controlledLeftWidth]); + + useEffect(() => { + if (!isDesktop) { + return undefined; + } + + const handleMouseMove = (event: MouseEvent) => { + if (!resizeStateRef.current) { + return; + } + + const { startX, startLeftWidth, containerWidth } = resizeStateRef.current; + const dragDelta = event.clientX - startX; + const dragPercent = (dragDelta / containerWidth) * 100; + const maxLeftWidth = 100 - minRightWidth; + const nextLeftWidth = Math.min( + maxLeftWidth, + Math.max(minLeftWidth, startLeftWidth + dragPercent), + ); + + setUncontrolledLeftWidth(nextLeftWidth); + onLeftWidthChange?.(nextLeftWidth); + }; + + const handleMouseUp = () => { + resizeStateRef.current = null; + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDesktop, minLeftWidth, minRightWidth, onLeftWidthChange]); + + const handleResizeStart = (event: React.MouseEvent) => { + if (!containerRef.current) { + return; + } + + event.preventDefault(); + resizeStateRef.current = { + startX: event.clientX, + startLeftWidth: leftWidth, + containerWidth: containerRef.current.getBoundingClientRect().width, + }; + }; + + if (!isDesktop) { + return ( + + {left} + {right} + + ); + } + + return ( + + + {left} + + + + {right} + + + ); +}; diff --git a/frontend/src/components/StatCell.tsx b/frontend/src/components/StatCell.tsx index 954ac962..1a6b2329 100644 --- a/frontend/src/components/StatCell.tsx +++ b/frontend/src/components/StatCell.tsx @@ -1,13 +1,24 @@ import { Stack, Typography } from "@mui/material"; -import { formatNumberData } from "../utils"; +import { formatNumberData } from "@/utils"; -export const StatCell = ({ label, value, isCount }: { label: string; value?: number, isCount?: boolean }) => { +export const StatCell = ({ + label, + value, + isCount, +}: { + label: string; + value?: number; + isCount?: boolean; +}) => { return ( {label} - {formatNumberData(value)}{isCount ? "" : " ppm"} + + {formatNumberData(value)} + {isCount ? "" : " ppm"} + ); -} +}; diff --git a/frontend/src/components/TabPanel.tsx b/frontend/src/components/TabPanel.tsx index 26cae3d7..b869d461 100644 --- a/frontend/src/components/TabPanel.tsx +++ b/frontend/src/components/TabPanel.tsx @@ -1,24 +1,24 @@ -import React from 'react' - -interface TabPanelProps { - children?: React.ReactNode - tabIndex: number - currentTabIndex: number -} - -export default function TabPanel({children, tabIndex, currentTabIndex}: TabPanelProps) { - return ( - - ) -} +import React from "react"; + +interface TabPanelProps { + children?: React.ReactNode; + tabIndex: number; + currentTabIndex: number; +} + +export const TabPanel = ({ + children, + tabIndex, + currentTabIndex, +}: TabPanelProps) => { + return ( + + ); +}; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 13ec25d5..8f5a6fba 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,43 +1,115 @@ -import { useState } from "react"; +import { MouseEvent, useState } from "react"; import { AppBar, - Toolbar, - Typography, - IconButton, - Avatar, - Menu, - MenuItem, - Button, + Badge, Box, + Button, Divider, + IconButton, ListItemIcon, + Menu, + MenuItem, + Toolbar, + Typography, + useMediaQuery, + useTheme, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; -import { useNavigate } from "react-router-dom"; +import CloseIcon from "@mui/icons-material/Close"; +import { + ExpandMore, + Home, + Logout, + MonitorHeart, + NotificationsOutlined, + Public, + Science, + Settings, +} from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; import { useAuthUser, useSignOut } from "react-auth-kit"; -import { Login, Logout, Settings } from "@mui/icons-material"; -import { RoleChip, TopbarUserButton } from "./index"; +import { TopbarUserButton, UserAvatar } from "@/components"; +import { + DESKTOP_COLLAPSED_WIDTH, + TOPBAR_HEIGHT, +} from "@/components/ui/sidebar"; +import { BgColor } from "@/constants"; +import { useIsActiveRoute } from "@/hooks"; +import { useGetUnreadNotificationCount } from "@/service"; -export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMenuClick: () => void; sx?: any }) { +export const Topbar = ({ + open, + sidebarWidth, + onMenuClick, + sx, +}: { + open: boolean; + sidebarWidth: number; + onMenuClick: () => void; + sx?: any; +}) => { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); + const isHomeActive = useIsActiveRoute("/"); + const isChloridesActive = useIsActiveRoute("/chlorides"); + const isMonitoringWellsActive = useIsActiveRoute("/monitoringwells"); + const isNotificationsActive = useIsActiveRoute("/notifications"); + const isSettingsActive = useIsActiveRoute("/settings"); - const [anchorEl, setAnchorEl] = useState(null); + const [userMenuAnchorEl, setUserMenuAnchorEl] = useState( + null, + ); + const [publicMenuAnchorEl, setPublicMenuAnchorEl] = + useState(null); - const role: string = authUser()?.user_role?.name; - const isLoggedIn = !!authUser(); + const user = authUser(); + const role: string = user?.user_role?.name; + const fullName = user?.full_name ?? user?.display_name ?? "Unknown"; + const email = user?.email ?? "No email available"; + const isLoggedIn = !!user; + const unreadNotificationsQuery = useGetUnreadNotificationCount({ + enabled: isLoggedIn, + }); + const unreadNotificationCount = + unreadNotificationsQuery.data?.unread_count ?? 0; + const isPublicDataActive = isChloridesActive || isMonitoringWellsActive; + const effectiveSidebarWidth = + isDesktop && isLoggedIn + ? open + ? sidebarWidth + : DESKTOP_COLLAPSED_WIDTH + : 0; - const handleMenuOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); + const handleMenuOpen = (event: MouseEvent) => { + setUserMenuAnchorEl(event.currentTarget); }; const handleMenuClose = () => { - setAnchorEl(null); + setUserMenuAnchorEl(null); + }; + + const handlePublicMenuOpen = (event: MouseEvent) => { + setPublicMenuAnchorEl(event.currentTarget); + }; + + const handlePublicMenuClose = () => { + setPublicMenuAnchorEl(null); + }; + + const handlePublicMenuToggle = (event: MouseEvent) => { + if (publicMenuAnchorEl) { + handlePublicMenuClose(); + return; + } + + handlePublicMenuOpen(event); }; const fullSignOut = () => { - navigate("/"); + navigate({ to: "/", search: {} }); localStorage.removeItem("loggedIn"); signOut(); }; @@ -45,131 +117,354 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen return ( - - - + + {!isDesktop ? ( + + {open ? : } + + ) : null} + + - - - {!open ? navigate("/")} + onClick={() => navigate({ to: "/", search: {} })} > Meter Manager - : null} + + {isDesktop && !isLoggedIn ? ( + + + + + { + navigate({ to: "/chlorides", search: {} }); + handlePublicMenuClose(); + }} + sx={{ + color: isChloridesActive ? "darkblue" : "text.primary", + "& .MuiListItemIcon-root": { + color: isChloridesActive ? "darkblue" : "action.active", + }, + }} + > + + + + Chlorides + + { + navigate({ to: "/monitoringwells", search: {} }); + handlePublicMenuClose(); + }} + sx={{ + color: isMonitoringWellsActive ? "darkblue" : "text.primary", + "& .MuiListItemIcon-root": { + color: isMonitoringWellsActive + ? "darkblue" + : "action.active", + }, + }} + > + + + + Monitoring Wells + + + + ) : null} + {isLoggedIn ? ( - + + navigate({ to: "/notifications", search: {} })} + sx={{ + width: { xs: 35, md: 40, lg: 44 }, + height: { xs: 35, md: 40, lg: 44 }, + color: isNotificationsActive ? "darkblue" : "text.secondary", + border: isNotificationsActive ? "1px solid" : undefined, + borderColor: isNotificationsActive + ? "rgba(0, 0, 139, 0.24)" + : undefined, + bgcolor: isNotificationsActive + ? "rgba(0, 0, 139, 0.08)" + : undefined, + "&:hover": { + bgcolor: isNotificationsActive + ? "rgba(0, 0, 139, 0.14)" + : undefined, + }, + }} + > + + + + - - Role: - - + + + + {fullName} + + + {email} + + { - navigate("/settings") - handleMenuClose() + navigate({ to: "/settings", search: {} }); + handleMenuClose(); }} + sx={{ minHeight: 36, gap: 1, px: 1.5 }} > - Account Settings + + Settings + - { - fullSignOut() - handleMenuClose() + navigate({ to: "/notifications", search: {} }); + handleMenuClose(); }} + sx={{ minHeight: 36, gap: 1, px: 1.5 }} > - + - Logout + + Notifications + - - - ) - : ( - - )} + + + + + Log out + + + + + ) : ( + + )} ); -} - +}; diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index 5cbd6fab..a7cf2971 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -1,73 +1,45 @@ -import { Avatar, Button, ButtonProps } from "@mui/material"; -import { getRoleColor } from "../utils"; -import { Badge, Engineering, Face } from "@mui/icons-material"; -import { useTheme } from "@mui/material/styles"; - +import { ButtonProps, IconButton } from "@mui/material"; +import { getRoleColor } from "@/utils"; +import { UserAvatar } from "@/components/UserAvatar"; export const TopbarUserButton = ({ - display_name, + full_name, role, src, ...buttonProps }: { - display_name: string, - role: string, - src?: string + full_name: string; + role: string; + src?: string; } & ButtonProps) => { - const theme = useTheme(); const buttonColor = getRoleColor(role); - const primary = theme.palette.primary; - const secondary = theme.palette.secondary; - const warning = theme.palette.warning; - - const roleIcons: Record = { - Admin: , - Technician: , - }; - - const renderRoleIcon = () => roleIcons[role] ?? ; - - const roleBgColor: Record = { - Admin: primary.dark, - Technician: secondary.dark, - OSE: warning.dark - } - - const roleBorderColor: Record = { - Admin: primary.contrastText, - Technician: secondary.contrastText, - OSE: warning.contrastText - } - return ( - + /> + ); -} +}; diff --git a/frontend/src/components/TristateToggle.tsx b/frontend/src/components/TristateToggle.tsx index c0cef2e1..880f36b3 100644 --- a/frontend/src/components/TristateToggle.tsx +++ b/frontend/src/components/TristateToggle.tsx @@ -1,44 +1,57 @@ -import { Chip } from "@mui/material"; -import { useEffect, useState } from "react"; +import { Chip, type ChipProps } from "@mui/material"; -export default function TristateToggle({ label, onToggle }: any) { - const [toggleState, setToggleState] = useState(); +export type TriString = "all" | "true" | "false"; - useEffect(() => { - onToggle(toggleState); - }, [toggleState]); - - function getColor() { - switch (toggleState) { - case true: +export const TristateToggle = ({ + label, + value, + onToggle, +}: { + label: string; + value: TriString; + onToggle: (value: TriString) => void; +}) => { + const getColor = (): ChipProps["color"] | undefined => { + switch (value) { + case "true": return "success"; - case false: + case "false": return "error"; default: return undefined; } - } + }; - function getLabel() { - switch (toggleState) { - case true: - return "Is " + label; - case false: - return "Is Not " + label; + const getLabel = () => { + switch (value) { + case "true": + return `Is ${label}`; + case "false": + return `Is Not ${label}`; default: return label; } - } + }; + + const nextValue = (v: TriString): TriString => { + switch (v) { + case "all": + return "true"; + case "true": + return "false"; + case "false": + return "all"; + } + }; return ( setToggleState(undefined) : undefined - } - onClick={() => setToggleState(!toggleState)} + variant={value === "all" ? "outlined" : "filled"} + onDelete={value === "all" ? undefined : () => onToggle("all")} + onClick={() => onToggle(nextValue(value))} /> ); -} +}; diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 00000000..c2f19d46 --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,57 @@ +import { Avatar, AvatarProps, useTheme } from "@mui/material"; +import { createAvatar } from "@dicebear/core"; +import { notionists } from "@dicebear/collection"; + +export const UserAvatar = ({ + full_name, + role, + src, + size = 40, + ...avatarProps +}: { + full_name: string; + role?: string; + src?: string | null; + size?: number; +} & AvatarProps) => { + const theme = useTheme(); + const primary = theme.palette.primary; + const secondary = theme.palette.secondary; + const warning = theme.palette.warning; + + const roleBgColor: Record = { + Admin: primary.dark, + Technician: secondary.dark, + OSE: warning.dark, + }; + + const roleRingColor: Record = { + Admin: primary.main, + Technician: secondary.main, + OSE: warning.main, + }; + + const fallbackSrc = src + ? src + : createAvatar(notionists, { + seed: full_name, + size: 64, + }).toDataUri(); + + return ( + + ); +}; diff --git a/frontend/src/components/UserSelection.tsx b/frontend/src/components/UserSelection.tsx index bf913158..814742ea 100644 --- a/frontend/src/components/UserSelection.tsx +++ b/frontend/src/components/UserSelection.tsx @@ -1,7 +1,7 @@ -import { User } from "../interfaces"; import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; -import { useGetUserList } from "../service/ApiServiceNew"; import { useAuthUser } from "react-auth-kit"; +import { useGetUserList } from "@/service"; +import { User } from "@/interfaces"; export default function UserSelection({ selectedUser, diff --git a/frontend/src/components/WellSelection.tsx b/frontend/src/components/WellSelection.tsx index e81c7880..03debe63 100644 --- a/frontend/src/components/WellSelection.tsx +++ b/frontend/src/components/WellSelection.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { TextField } from "@mui/material"; import { useDebounce } from "use-debounce"; import { Autocomplete } from "@mui/material"; -import { useGetWells } from "../service/ApiServiceNew"; -import { Well } from "../interfaces"; +import { useGetWells } from "@/service"; +import { Well } from "@/interfaces"; export default function WellSelection({ selectedWell, diff --git a/frontend/src/components/WorkOrderSelect.tsx b/frontend/src/components/WorkOrderSelect.tsx index 3eaa7ea2..fdc1ac80 100644 --- a/frontend/src/components/WorkOrderSelect.tsx +++ b/frontend/src/components/WorkOrderSelect.tsx @@ -2,61 +2,85 @@ A simple select component that limits options based on filters. */ -import React, { useEffect } from 'react' -import { FormControl, InputLabel, MenuItem, Select } from '@mui/material' -import { useGetWorkOrders } from '../service/ApiServiceNew' -import { WorkOrderStatus } from '../enums' -import { WorkOrder } from '../interfaces' +import { useEffect, useState } from "react"; +import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; +import { useGetWorkOrders } from "@/service"; +import { WorkOrderStatus } from "@/enums"; +import { WorkOrder } from "@/interfaces"; interface WorkOrderSelectFilters { - meter_serial?: string - assigned_user_id?: number - date_created?: Date + meter_serial?: string; + assigned_user_id?: number; + date_created?: Date; } interface WorkOrderSelectProps { - selectedWorkOrderID: number | null - setSelectedWorkOrderID: (workOrderID: number | null) => void - option_filters?: WorkOrderSelectFilters + selectedWorkOrderID: number | null; + setSelectedWorkOrderID: (workOrderID: number | null) => void; + option_filters?: WorkOrderSelectFilters; } -function optionsFilter(workOrders: WorkOrder[], filters: WorkOrderSelectFilters) { - //Use the filter method for each component of the filter - if (filters.meter_serial && filters.meter_serial !== undefined) { - workOrders = workOrders.filter((workOrder) => workOrder.meter_serial === filters.meter_serial) - } - if (filters.assigned_user_id && filters.assigned_user_id !== undefined) { - workOrders = workOrders.filter((workOrder) => workOrder.assigned_user_id === filters.assigned_user_id) - } - if (filters.date_created && filters.date_created !== undefined) { - workOrders = workOrders.filter((workOrder) => workOrder.date_created === filters.date_created) - } - return workOrders +function optionsFilter( + workOrders: WorkOrder[], + filters: WorkOrderSelectFilters, +) { + //Use the filter method for each component of the filter + if (filters.meter_serial && filters.meter_serial !== undefined) { + workOrders = workOrders.filter( + (workOrder) => workOrder.meter_serial === filters.meter_serial, + ); + } + if (filters.assigned_user_id && filters.assigned_user_id !== undefined) { + workOrders = workOrders.filter( + (workOrder) => workOrder.assigned_user_id === filters.assigned_user_id, + ); + } + if (filters.date_created && filters.date_created !== undefined) { + workOrders = workOrders.filter( + (workOrder) => workOrder.date_created === filters.date_created, + ); + } + return workOrders; } -export default function WorkOrderSelect({selectedWorkOrderID, setSelectedWorkOrderID, option_filters}: WorkOrderSelectProps) { - const workOrderList = useGetWorkOrders([WorkOrderStatus['Open']]) - const [filteredWorkOrders, setFilteredWorkOrders] = React.useState([]) +export default function WorkOrderSelect({ + selectedWorkOrderID, + setSelectedWorkOrderID, + option_filters, +}: WorkOrderSelectProps) { + const workOrderList = useGetWorkOrders({ + filter_by_status: [WorkOrderStatus.Open], + }); + const [filteredWorkOrders, setFilteredWorkOrders] = useState([]); - useEffect(() => { - if (workOrderList.data) { - setFilteredWorkOrders(optionsFilter(workOrderList.data, option_filters ?? {})); - } - }, [workOrderList, option_filters]); + useEffect(() => { + if (workOrderList.data) { + setFilteredWorkOrders( + optionsFilter(workOrderList.data, option_filters ?? {}), + ); + } + }, [workOrderList, option_filters]); - return ( - - Work Order - setSelectedWorkOrderID(event.target.value)} + > + None + {filteredWorkOrders.map((workOrder: WorkOrder) => { + return ( + - None - {filteredWorkOrders.map((workOrder: WorkOrder) => { - return {workOrder.title} - })} - - - ) -} \ No newline at end of file + {workOrder.title} + + ); + })} + + + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 10191177..bfbfdbae 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,30 +1,41 @@ -export * from './BackgroundBox' -export * from './ModalBackgroundBox' -export * from './TristateToggle' -export * from './TopbarUserButton' -export * from './ChipSelect' -export * from './MergeWellModal' -export * from './UserSelection' -export * from './CustomCardHeader' -export * from './MeterRegisterSelect' -export * from './RHControlled' -export * from './ReportsNavItem' -export * from './RoleChip' -export * from './IsTrueChip' -export * from './DirectionCard' -export * from './MeterSelection' -export * from './StatCell' -export * from './StyledToggleButton' -export * from './WellSelection' -export * from './MeterTypeSelect' -export * from './TabPanel' -export * from './Layers' -export * from './WorkOrderSelect' -export * from './GridFooterWithButton' -export * from './NavLink' -export * from './Topbar' -export * from './WellMapLegend' -export * from './ImagePreviewGrid' -export * from './ImageUploadWithPreview' -export * from './ImageDialog' -export * from './Modals' +export * from "./AvatarPicker"; +export * from "./BackgroundBox"; +export * from "./ChipSelect"; +export * from "./CustomCardHeader"; +export * from "./DMSentry"; +export * from "./DirectionCard"; +export * from "./EventTypeChip"; +export * from "./GridFooterWithButton"; +export * from "./ImageDialog"; +export * from "./ImagePreviewGrid"; +export * from "./ImageUploadWithPreview"; +export * from "./IsTrueChip"; +export * from "./Layers"; +export * from "./LinkBehavior"; +export * from "./ManageBreadcrumbTitle"; +export * from "./MapFullscreenToggle"; +export * from "./MapUrlStateSync"; +export * from "./MergeWellModal"; +export * from "./MeterMapColorLegend"; +export * from "./MeterRegisterSelect"; +export * from "./MeterSelection"; +export * from "./MeterTypeSelect"; +export * from "./ModalBackgroundBox"; +export * from "./Modals"; +export * from "./NavLink"; +export * from "./ReportsNavItem"; +export * from "./ReportBreadcrumbTitle"; +export * from "./RHControlled"; +export * from "./ResizableSplitPanels"; +export * from "./RoleChip"; +export * from "./StatCell"; +export * from "./StyledToggleButton"; +export * from "./TabPanel"; +export * from "./Topbar"; +export * from "./TopbarUserButton"; +export * from "./TristateToggle"; +export * from "./UserAvatar"; +export * from "./UserSelection"; +export * from "./WellMapLegend"; +export * from "./WellSelection"; +export * from "./WorkOrderSelect"; diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..2147da14 --- /dev/null +++ b/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,393 @@ +import { + Box, + BoxProps, + IconButton, + alpha, + SxProps, + Theme, + Tooltip, + tooltipClasses, + TooltipProps, + styled, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { ChevronLeft, ChevronRight } from "@mui/icons-material"; +import { + MouseEvent as ReactMouseEvent, + ReactNode, + useEffect, + useRef, +} from "react"; +import { BgColor } from "@/constants"; + +export const DESKTOP_MIN_WIDTH = 240; +export const DESKTOP_MAX_WIDTH = 420; +export const DESKTOP_COLLAPSED_WIDTH = 70; +export const DESKTOP_AUTO_COLLAPSE_WIDTH = 170; +export const TOPBAR_HEIGHT = { + xs: "40px", + sm: "44px", +}; + +const panelSurfaceSx: SxProps = { + display: "flex", + flexDirection: "column", + height: "100%", + overflow: "hidden", + borderRight: "1px solid", + borderColor: "divider", + backgroundColor: BgColor, + boxShadow: "0 20px 45px rgba(15, 23, 42, 0.08)", + backdropFilter: "blur(12px)", +}; + +const ShadcnTooltipRoot = styled(({ className, ...props }: TooltipProps) => ( + +))(() => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: "rgba(15, 23, 42, 0.96)", + color: "#f8fafc", + borderRadius: 10, + padding: "8px 10px", + fontSize: 12, + fontWeight: 500, + boxShadow: "0 12px 28px rgba(15, 23, 42, 0.25)", + }, + [`& .${tooltipClasses.arrow}`]: { + color: "rgba(15, 23, 42, 0.96)", + }, +})); + +export function SidebarTooltip(props: TooltipProps) { + return ; +} + +export function Sidebar({ + open, + width, + collapsedWidth = DESKTOP_COLLAPSED_WIDTH, + onClose, + onOpen, + onWidthChange, + children, +}: { + open: boolean; + width: number; + collapsedWidth?: number; + onClose: () => void; + onOpen: () => void; + onWidthChange: (width: number) => void; + children: ReactNode; +}) { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); + const resizeStateRef = useRef<{ startX: number; startWidth: number } | null>( + null, + ); + + useEffect(() => { + if (!isDesktop) { + return undefined; + } + + const handleMouseMove = (event: MouseEvent) => { + if (!resizeStateRef.current) { + return; + } + + const dragDelta = event.clientX - resizeStateRef.current.startX; + const nextWidth = resizeStateRef.current.startWidth + dragDelta; + const isExpandingFromCollapsed = !open && dragDelta > 0; + + if (isExpandingFromCollapsed) { + onOpen(); + onWidthChange( + Math.min( + DESKTOP_MAX_WIDTH, + Math.max(DESKTOP_MIN_WIDTH, width + dragDelta), + ), + ); + return; + } + + if (nextWidth <= DESKTOP_AUTO_COLLAPSE_WIDTH) { + resizeStateRef.current = null; + onClose(); + return; + } + + onOpen(); + onWidthChange( + Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, nextWidth)), + ); + }; + + const handleMouseUp = () => { + resizeStateRef.current = null; + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [collapsedWidth, isDesktop, onClose, onOpen, onWidthChange, open]); + + const handleResizeStart = (event: ReactMouseEvent) => { + event.preventDefault(); + resizeStateRef.current = { + startX: event.clientX, + startWidth: open ? width : collapsedWidth, + }; + }; + + if (isDesktop) { + const desktopWidth = open ? width : collapsedWidth; + + return ( + + + {children} + + + + ); + } + + return ( + <> + + + {children} + + + ); +} + +export function SidebarHeader({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} + +export function SidebarHeaderCloseButton({ + onClick, + direction = "left", + mobile = false, +}: { + onClick: () => void; + direction?: "left" | "right"; + mobile?: boolean; +}) { + return ( + + {direction === "left" ? ( + + ) : ( + + )} + + ); +} + +export function SidebarContent({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} + +export function SidebarGroup({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} + +export function SidebarGroupLabel({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} + +export function SidebarMenu({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} + +export function SidebarMenuSub({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} + +export function SidebarInset({ children, sx, ...props }: BoxProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5c904958..ae523f8f 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1 +1,7 @@ export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export const ROLE_IDS = { + TECHNICIAN: 1, + ADMIN: 2, + OSE: 3, +}; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index b384d847..410b1916 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,18 +1,23 @@ import { - Home as HomeIcon, - FormatListBulletedOutlined, - ScreenshotMonitor, - Construction, + AssignmentTurnedInOutlined, MonitorHeart, - Plumbing, Build, Science, People, Storage, + MonitorHeartOutlined, + ConstructionOutlined, + BuildOutlined, + ScienceOutlined, + WaterDrop, + SpeedOutlined, + Engineering, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; +export const BgColor = "#F8FAFC"; + type NavItem = { path: string; label: string; @@ -23,7 +28,6 @@ type NavItem = { }; export const navConfig: NavItem[] = [ - { path: "/", label: "Home", icon: HomeIcon }, { path: "/chlorides", label: "Chlorides", icon: Science }, { path: "/monitoringwells", label: "Monitoring Wells", icon: MonitorHeart }, @@ -31,54 +35,54 @@ export const navConfig: NavItem[] = [ { path: "/workorders", label: "Work Orders", - icon: FormatListBulletedOutlined, + icon: AssignmentTurnedInOutlined, role: "Technician", }, { path: "/activities", label: "Activities", - icon: Construction, + icon: Engineering, role: "Technician", }, { path: "/manage/meters", label: "Manage Meters", - icon: ScreenshotMonitor, + icon: SpeedOutlined, role: "Technician", }, { path: "/manage/wells", label: "Manage Wells", - icon: Plumbing, + icon: WaterDrop, role: "Technician", }, // Reports + { + path: "/reports/chlorides", + label: "Chlorides", + icon: ScienceOutlined, + role: "Technician", + parent: "reports", + }, { path: "/reports/monitoringwells", label: "Monitoring Wells", - icon: MonitorHeart, + icon: MonitorHeartOutlined, role: "Technician", parent: "reports", }, { path: "/reports/maintenance", label: "Maintenance", - icon: Construction, + icon: ConstructionOutlined, role: "Technician", parent: "reports", }, { path: "/reports/partsused", label: "Parts Used", - icon: Build, - role: "Technician", - parent: "reports", - }, - { - path: "/reports/chlorides", - label: "Chlorides", - icon: Science, + icon: BuildOutlined, role: "Technician", parent: "reports", }, diff --git a/frontend/src/contexts/ErrorMessageContext.tsx b/frontend/src/contexts/ErrorMessageContext.tsx new file mode 100644 index 00000000..384ab81c --- /dev/null +++ b/frontend/src/contexts/ErrorMessageContext.tsx @@ -0,0 +1,46 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { enqueueSnackbar } from "notistack"; + +type ErrorMessageContextValue = { + setErrorMessage: (msg?: string) => void; +}; + +const ErrorMessageContext = createContext(null); + +export const ErrorMessageProvider = ({ + children, +}: { + children: ReactNode; +}) => { + // Showing messages between navigation (eg: accessing forbidden page, accessing while not logged in) + // results in duplicated snackbars, this is a workaround. + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + if (errorMessage) { + enqueueSnackbar(errorMessage, { variant: "error" }); + } + }, [errorMessage]); + + return ( + + {children} + + ); +}; + +export const useErrorMessage = () => { + const context = useContext(ErrorMessageContext); + if (!context) { + throw new Error( + "useErrorMessage must be used within an ErrorMessageProvider", + ); + } + return context; +}; diff --git a/frontend/src/hooks/useFetchST2.ts b/frontend/src/hooks/useFetchST2.ts index a3dd4bf7..e41450fe 100644 --- a/frontend/src/hooks/useFetchST2.ts +++ b/frontend/src/hooks/useFetchST2.ts @@ -1,5 +1,5 @@ -import { formatQueryParams } from "../utils/HttpUtils"; -import { ST2Measurement, ST2Response } from "../interfaces"; +import { formatQueryParams } from "@/utils"; +import { ST2Measurement, ST2Response } from "@/interfaces"; export const useFetchST2 = () => { const ST2_API_BASE_URL = diff --git a/frontend/src/hooks/useFetchWithAuth.ts b/frontend/src/hooks/useFetchWithAuth.ts index 6ac7fbf3..7fb702bc 100644 --- a/frontend/src/hooks/useFetchWithAuth.ts +++ b/frontend/src/hooks/useFetchWithAuth.ts @@ -1,9 +1,9 @@ import { useAuthHeader, useSignOut } from "react-auth-kit"; -import { useNavigate } from "react-router-dom"; -import { formatQueryParams } from "../utils/HttpUtils"; +import { useNavigate } from "@tanstack/react-router"; +import { formatQueryParams } from "@/utils"; import { enqueueSnackbar } from "notistack"; -import { HttpStatus } from "../enums"; -import { API_URL } from "../config"; +import { HttpStatus } from "@/enums"; +import { API_URL } from "@/config"; export const useFetchWithAuth = () => { const authHeader = useAuthHeader(); @@ -24,19 +24,24 @@ export const useFetchWithAuth = () => { responseType?: "json" | "blob" | "text" | "response"; }) => { const url = `${API_URL}${route}${formatQueryParams(params)}`; + const isFormData = body instanceof FormData; const response = await fetch(url, { method, headers: { Authorization: authHeader(), // Only set JSON content-type when sending JSON - ...(body && ["PATCH", "POST", "PUT", "DELETE"].includes(method) + ...(body && + !isFormData && + ["PATCH", "POST", "PUT", "DELETE"].includes(method) ? { "Content-Type": "application/json" } : {}), }, body: body && ["PATCH", "POST", "PUT", "DELETE"].includes(method) - ? JSON.stringify(body) + ? isFormData + ? body + : JSON.stringify(body) : undefined, }); @@ -46,7 +51,7 @@ export const useFetchWithAuth = () => { localStorage.getItem("loggedIn") ) { localStorage.removeItem("loggedIn"); - navigate("/"); + navigate({ to: "/" }); signOut(); enqueueSnackbar("Session expired. Please log in to continue.", { variant: "error", diff --git a/frontend/src/hooks/useIsActiveRoute.ts b/frontend/src/hooks/useIsActiveRoute.ts index 20d5965f..72294fb8 100644 --- a/frontend/src/hooks/useIsActiveRoute.ts +++ b/frontend/src/hooks/useIsActiveRoute.ts @@ -1,10 +1,11 @@ -import { useLocation } from "react-router-dom"; +import { useRouterState } from "@tanstack/react-router"; type RouteLike = string | { pathname?: string }; export function useIsActiveRoute(route: RouteLike): boolean { - const location = useLocation(); - const currentPath = location.pathname; + const currentPath = useRouterState({ + select: (state) => state.location.pathname, + }); // normalize target path (strip query & hash) const targetPath = diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts deleted file mode 100644 index 50ca0400..00000000 --- a/frontend/src/interfaces.d.ts +++ /dev/null @@ -1,685 +0,0 @@ -import { SortDirection, MeterSortByField, WellSortByField } from 'enums' -import internal from 'stream' -import { ActivityType, MeterStatusNames } from './enums' -import { DateCalendarClassKey } from '@mui/x-date-pickers' -import dayjs from 'dayjs' -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 - } - - observations?: ObservationForm[] - - maintenance_repair?: { - service_type_ids: number[] - description: string - } - - notes?: { - working_on_arrival_slug: string - selected_note_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_id: number | null - unit_id: number | null - }>, - maintenance_repair?: { - service_type_ids: number[] | null, - description: string - }, - notes: { - working_on_arrival_slug: string, - selected_note_ids: number[] | null - }, - photos?: File[], - 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?: [] -} - -//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 - - well: Well | null - water_users?: string - - notes?: NoteTypeLU[] - services?: ServiceTypeLU[] - parts_used?: Part[] - - 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 -} - -//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 -} - -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 -} - -export interface ObservationForm { - time: Dayjs - reading: '' | number - property_type_id: '' | number - unit_id: '' | number -} - -export interface WellUseLU { - id: number - use_type?: string - code?: string - description?: string -} - -export interface PartTypeLU { - 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 - - part_type?: PartTypeLU - meter_types?: MeterTypeLU[] - -} - -export interface PartAssociation { - id: int - meter_type_id: int - part_id: int - commonly_used: boolean - part?: Part -} - -export interface ServiceTypeLU { - id: number - service_name: string - description?: string -} - -export interface NoteTypeLU { - id: number - note: string - details?: string - slug?: string - commonly_used: boolean -} - -export interface WellUseLU { - id: number - use_type: string - code: string - description: string -} - -export interface WaterSource { - id: number - name: string - description: string -} - -export interface WellStatus { - 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 - } - - 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 -} - -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 - } - ] -} - -export interface WellUpdate extends BaseWell { - use_type: WellUseLU - water_source: WaterSource - location: Location - well_status: WellStatus -} - -export interface MeterDetailsQueryParams { - meter_id: number | undefined -} - -export interface MeterPartParams { - meter_id: number | undefined -} - -export interface WellDetailsQueryParams { - well_id: number | undefined -} - -export interface WaterLevelQueryParams { - well_id: number | undefined -} - -export interface WellMergeParams { - merge_well: string - target_well: string -} - -export interface ST2WaterLevelQueryParams { - $filter: string - $orderby: string - datastreamID: number | undefined -} - -export interface ActivityTypeLU { - id: number - name: string - description: string - permission: string -} - -export interface ObservedPropertyTypeLU { - id: number - name: string - description: string - context: string - - units?: Unit[] -} - -export interface Unit { - 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 - photos: any -} - -export interface MeterType { - 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 -} - -export interface MeterStatus { - 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 -} - -export interface Location { - name: string - latitude: float - longitude: float - trss: string - land_owner_id: number - - 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 -} - -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] -} - -export interface MeterListQueryParams { - 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 -} - -export interface Organization { - organization_name: string -} - -export interface Meter { - id: number - serial_number: string - contact_name?: string - contact_phone?: string - notes?: string - price?: 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 -} - -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 - } -} - -interface WellListQueryParams { - 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 -} - -export interface MeterListQuery { - 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 } - 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 } -} - -// 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 -} - -// Whole response returned from a NM ST2 endpoint -export interface ST2Response { - "@iot.nextLink": string - value: [] -} - -// The object that gets sent to the backend to add a new measurement -export interface NewWellMeasurement { - 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 -} - -export interface NewRegionMeasurement { - region_id: number - timestamp: string - value?: number | null - 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 | null -} - -export interface CreateUser { - 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 -} - -export interface User { - id: number - username?: string - full_name: string - display_name?: string - email?: scope_string - disabled: boolean - user_role_id?: number - user_role?: UserRole - password?: string -} - -export interface NewUser { - 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[] -} - -export interface SecurityScope { - 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[] -} - -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 -} - -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 -} - -export interface MonitoredWell { - id: number; - name: string; - ra_number: string; - datastream_id: number; - well_status: WellStatus; - outside_recorder?: boolean; - chloride_group_id?: number; -} - -export interface MonitoredRegion { - id: number; - name: string; - datastream_id: number; - well_status: WellStatus; - outside_recorder?: boolean; -} diff --git a/frontend/src/interfaces/ActivityForm.ts b/frontend/src/interfaces/ActivityForm.ts new file mode 100644 index 00000000..94e55bdb --- /dev/null +++ b/frontend/src/interfaces/ActivityForm.ts @@ -0,0 +1,38 @@ +import type { Dayjs } from "dayjs"; +import type { ObservationForm } from "./ObservationForm"; + +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; + }; + + observations?: ObservationForm[]; + + maintenance_repair?: { + service_type_ids: number[]; + description: string; + }; + + notes?: { + working_on_arrival_slug: string; + selected_note_ids: number[]; + }; + + part_used_ids?: number[]; +} diff --git a/frontend/src/interfaces/ActivityFormControl.ts b/frontend/src/interfaces/ActivityFormControl.ts new file mode 100644 index 00000000..b09886b6 --- /dev/null +++ b/frontend/src/interfaces/ActivityFormControl.ts @@ -0,0 +1,41 @@ +import type { Dayjs } from "dayjs"; +import type { ActivityTypeLU } from "./ActivityTypeLU"; +import type { MeterDetails } from "./MeterDetails"; +import type { MeterListDTO } from "./MeterListDTO"; +import type { User } from "./User"; +import type { Well } from "./Well"; + +// 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; + work_order_id: number | null; + }; + current_installation: { + meter: Partial | null; + well: Partial | null; + }; + observations: Array<{ + time: Dayjs; + reading: "" | number; + property_type_id: number | null; + unit_id: number | null; + }>; + maintenance_repair?: { + service_type_ids: number[] | null; + description: string; + }; + notes: { + working_on_arrival_slug: string; + selected_note_ids: number[] | null; + }; + photos?: File[]; + part_used_ids?: []; +} diff --git a/frontend/src/interfaces/ActivityTypeLU.ts b/frontend/src/interfaces/ActivityTypeLU.ts new file mode 100644 index 00000000..4755c359 --- /dev/null +++ b/frontend/src/interfaces/ActivityTypeLU.ts @@ -0,0 +1,6 @@ +export interface ActivityTypeLU { + id: number; + name: string; + description: string; + permission: string; +} diff --git a/frontend/src/interfaces/BaseWell.ts b/frontend/src/interfaces/BaseWell.ts new file mode 100644 index 00000000..e12efce7 --- /dev/null +++ b/frontend/src/interfaces/BaseWell.ts @@ -0,0 +1,15 @@ +export 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; +} diff --git a/frontend/src/interfaces/CreateNotificationPayload.ts b/frontend/src/interfaces/CreateNotificationPayload.ts new file mode 100644 index 00000000..82c67134 --- /dev/null +++ b/frontend/src/interfaces/CreateNotificationPayload.ts @@ -0,0 +1,8 @@ +export interface CreateNotificationPayload { + role_ids: number[]; + user_ids: number[]; + notification_type_id: number; + title: string; + message: string; + link?: string; +} diff --git a/frontend/src/interfaces/CreateUser.ts b/frontend/src/interfaces/CreateUser.ts new file mode 100644 index 00000000..5b35518d --- /dev/null +++ b/frontend/src/interfaces/CreateUser.ts @@ -0,0 +1,10 @@ +import type { scope_string } from "./primitives"; + +export interface CreateUser { + username: string; + full_name: string; + email: scope_string; + disabled: boolean; + user_role: { id: number }; + password: string; +} diff --git a/frontend/src/interfaces/HomeSummary.ts b/frontend/src/interfaces/HomeSummary.ts new file mode 100644 index 00000000..d32d133f --- /dev/null +++ b/frontend/src/interfaces/HomeSummary.ts @@ -0,0 +1,6 @@ +export interface HomeSummary { + completed_work_orders: number; + repairs_processed: number; + reinstallations_processed: number; + preventative_maintenance_processed: number; +} diff --git a/frontend/src/interfaces/IncreaseQuantityPayload.ts b/frontend/src/interfaces/IncreaseQuantityPayload.ts new file mode 100644 index 00000000..46089841 --- /dev/null +++ b/frontend/src/interfaces/IncreaseQuantityPayload.ts @@ -0,0 +1,6 @@ +export interface IncreaseQuantityPayload { + part_id: number | string; + count: number; + date: string | undefined; // YYYY-MM-DD + note?: string; +} diff --git a/frontend/src/interfaces/LandOwner.ts b/frontend/src/interfaces/LandOwner.ts new file mode 100644 index 00000000..611ceced --- /dev/null +++ b/frontend/src/interfaces/LandOwner.ts @@ -0,0 +1,9 @@ +export interface LandOwner { + id: number; + contact_name?: string; + land_owner_name?: string; + organization?: string; + phone?: string; + email?: string; + city?: string; +} diff --git a/frontend/src/interfaces/Location.ts b/frontend/src/interfaces/Location.ts new file mode 100644 index 00000000..0d6f938d --- /dev/null +++ b/frontend/src/interfaces/Location.ts @@ -0,0 +1,12 @@ +import type { float } from "./primitives"; +import type { LandOwner } from "./LandOwner"; + +export interface Location { + name: string; + latitude: float; + longitude: float; + trss: string; + land_owner_id: number; + + land_owner?: LandOwner; +} diff --git a/frontend/src/interfaces/Meter.ts b/frontend/src/interfaces/Meter.ts new file mode 100644 index 00000000..10d9aa9f --- /dev/null +++ b/frontend/src/interfaces/Meter.ts @@ -0,0 +1,25 @@ +import type { Location } from "./Location"; +import type { MeterRegister } from "./MeterRegister"; +import type { MeterStatus } from "./MeterStatus"; +import type { MeterType } from "./MeterType"; +import type { Well } from "./Well"; + +export interface Meter { + id: number; + serial_number: string; + contact_name?: string; + contact_phone?: string; + notes?: string; + price?: 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; +} diff --git a/frontend/src/interfaces/MeterActivity.ts b/frontend/src/interfaces/MeterActivity.ts new file mode 100644 index 00000000..6316bbf8 --- /dev/null +++ b/frontend/src/interfaces/MeterActivity.ts @@ -0,0 +1,22 @@ +import type { int } from "./primitives"; +import type { ActivityTypeLU } from "./ActivityTypeLU"; +import type { Location } from "./Location"; +import type { Meter } from "./Meter"; +import type { User } from "./User"; + +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?: []; +} diff --git a/frontend/src/interfaces/MeterDetails.ts b/frontend/src/interfaces/MeterDetails.ts new file mode 100644 index 00000000..49266399 --- /dev/null +++ b/frontend/src/interfaces/MeterDetails.ts @@ -0,0 +1,26 @@ +import type { float, int } from "./primitives"; +import type { MeterRegister } from "./MeterRegister"; +import type { MeterStatus } from "./MeterStatus"; +import type { MeterType } from "./MeterType"; +import type { Well } from "./Well"; + +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] +} diff --git a/frontend/src/interfaces/MeterDetailsQueryParams.ts b/frontend/src/interfaces/MeterDetailsQueryParams.ts new file mode 100644 index 00000000..39b33bfc --- /dev/null +++ b/frontend/src/interfaces/MeterDetailsQueryParams.ts @@ -0,0 +1,3 @@ +export interface MeterDetailsQueryParams { + meter_id: number | undefined; +} diff --git a/frontend/src/interfaces/MeterHistoryDTO.ts b/frontend/src/interfaces/MeterHistoryDTO.ts new file mode 100644 index 00000000..ec55c068 --- /dev/null +++ b/frontend/src/interfaces/MeterHistoryDTO.ts @@ -0,0 +1,14 @@ +import type { int } from "./primitives"; +import type { Location } from "./Location"; +import type { Well } from "./Well"; + +export interface MeterHistoryDTO { + id: int; + history_type: string; + activity_type: string; + date: Date; + history_item: any; + location: Location; + well: Well | null; + photos: any; +} diff --git a/frontend/src/interfaces/MeterListDTO.ts b/frontend/src/interfaces/MeterListDTO.ts new file mode 100644 index 00000000..30cbb5eb --- /dev/null +++ b/frontend/src/interfaces/MeterListDTO.ts @@ -0,0 +1,16 @@ +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; + }; +} diff --git a/frontend/src/interfaces/MeterListQuery.ts b/frontend/src/interfaces/MeterListQuery.ts new file mode 100644 index 00000000..20ad11c2 --- /dev/null +++ b/frontend/src/interfaces/MeterListQuery.ts @@ -0,0 +1,10 @@ +import type { SortDirection } from "@/enums"; +import type { MeterListSortBy } from "./MeterListSortBy"; + +export interface MeterListQuery { + search_string: string; + sort_by: MeterListSortBy; + sort_direction: SortDirection; + limit: number; + offset: number; +} diff --git a/frontend/src/interfaces/MeterListQueryParams.ts b/frontend/src/interfaces/MeterListQueryParams.ts new file mode 100644 index 00000000..46056f8e --- /dev/null +++ b/frontend/src/interfaces/MeterListQueryParams.ts @@ -0,0 +1,10 @@ +import type { MeterSortByField, MeterStatusNames, SortDirection } from "@/enums"; + +export interface MeterListQueryParams { + search_string?: string; + filter_by_status?: MeterStatusNames[]; + sort_by?: MeterSortByField; + sort_direction?: SortDirection; + limit?: number; + offset?: number; +} diff --git a/frontend/src/interfaces/MeterListSortBy.ts b/frontend/src/interfaces/MeterListSortBy.ts new file mode 100644 index 00000000..683204b6 --- /dev/null +++ b/frontend/src/interfaces/MeterListSortBy.ts @@ -0,0 +1,3 @@ +import type { MeterSortByField } from "@/enums"; + +export type MeterListSortBy = MeterSortByField; diff --git a/frontend/src/interfaces/MeterMapDTO.ts b/frontend/src/interfaces/MeterMapDTO.ts new file mode 100644 index 00000000..55b97326 --- /dev/null +++ b/frontend/src/interfaces/MeterMapDTO.ts @@ -0,0 +1,13 @@ +export interface MeterMapDTO { + id: number; + serial_number: string; + well: { + ra_number: string; + name: string; + }; + location: { + longitude: number; + latitude: number; + }; + last_pm: string; +} diff --git a/frontend/src/interfaces/MeterPartParams.ts b/frontend/src/interfaces/MeterPartParams.ts new file mode 100644 index 00000000..18368956 --- /dev/null +++ b/frontend/src/interfaces/MeterPartParams.ts @@ -0,0 +1,3 @@ +export interface MeterPartParams { + meter_id: number | undefined; +} diff --git a/frontend/src/interfaces/MeterRegister.ts b/frontend/src/interfaces/MeterRegister.ts new file mode 100644 index 00000000..24873aca --- /dev/null +++ b/frontend/src/interfaces/MeterRegister.ts @@ -0,0 +1,13 @@ +import type { Unit } from "./Unit"; + +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; +} diff --git a/frontend/src/interfaces/MeterStatus.ts b/frontend/src/interfaces/MeterStatus.ts new file mode 100644 index 00000000..1294633d --- /dev/null +++ b/frontend/src/interfaces/MeterStatus.ts @@ -0,0 +1,5 @@ +export interface MeterStatus { + id: number; + status_name?: string; + description?: string; +} diff --git a/frontend/src/interfaces/MeterType.ts b/frontend/src/interfaces/MeterType.ts new file mode 100644 index 00000000..1f84b725 --- /dev/null +++ b/frontend/src/interfaces/MeterType.ts @@ -0,0 +1,10 @@ +import type { float, int } from "./primitives"; + +export interface MeterType { + id?: int; + brand?: string; + series?: string; + model?: string; + size?: float; + description?: string; +} diff --git a/frontend/src/interfaces/MeterTypeLU.ts b/frontend/src/interfaces/MeterTypeLU.ts new file mode 100644 index 00000000..92cd86c5 --- /dev/null +++ b/frontend/src/interfaces/MeterTypeLU.ts @@ -0,0 +1,10 @@ +//Depricate this??? need to assess +export interface MeterTypeLU { + id: number; + brand: string; + series: string; + model: string; + size: number; + description: string; + in_use: boolean; +} diff --git a/frontend/src/interfaces/MonitoredRegion.ts b/frontend/src/interfaces/MonitoredRegion.ts new file mode 100644 index 00000000..cd74b0be --- /dev/null +++ b/frontend/src/interfaces/MonitoredRegion.ts @@ -0,0 +1,9 @@ +import type { WellStatus } from "./WellStatus"; + +export interface MonitoredRegion { + id: number; + name: string; + datastream_id: number; + well_status: WellStatus; + outside_recorder?: boolean; +} diff --git a/frontend/src/interfaces/MonitoredWell.ts b/frontend/src/interfaces/MonitoredWell.ts new file mode 100644 index 00000000..4ffabb3f --- /dev/null +++ b/frontend/src/interfaces/MonitoredWell.ts @@ -0,0 +1,11 @@ +import type { WellStatus } from "./WellStatus"; + +export interface MonitoredWell { + id: number; + name: string; + ra_number: string; + datastream_id: number; + well_status: WellStatus; + outside_recorder?: boolean; + chloride_group_id?: number; +} diff --git a/frontend/src/interfaces/NewRegionMeasurement.ts b/frontend/src/interfaces/NewRegionMeasurement.ts new file mode 100644 index 00000000..66e33473 --- /dev/null +++ b/frontend/src/interfaces/NewRegionMeasurement.ts @@ -0,0 +1,7 @@ +export interface NewRegionMeasurement { + region_id: number; + timestamp: string; + value: number | null; + submitting_user_id: number; + well_id: number; +} diff --git a/frontend/src/interfaces/NewUser.ts b/frontend/src/interfaces/NewUser.ts new file mode 100644 index 00000000..398b1c23 --- /dev/null +++ b/frontend/src/interfaces/NewUser.ts @@ -0,0 +1,11 @@ +import type { scope_string } from "./primitives"; + +export interface NewUser { + id: number; + username: string; + full_name: string; + email: scope_string; + disabled: boolean; + user_role_id: number; + password: string; +} diff --git a/frontend/src/interfaces/NewWellMeasurement.ts b/frontend/src/interfaces/NewWellMeasurement.ts new file mode 100644 index 00000000..963d6e5f --- /dev/null +++ b/frontend/src/interfaces/NewWellMeasurement.ts @@ -0,0 +1,6 @@ +export interface NewWellMeasurement { + well_id: number; + timestamp: string; + value: number | null; + submitting_user_id: number; +} diff --git a/frontend/src/interfaces/NewWorkOrder.ts b/frontend/src/interfaces/NewWorkOrder.ts new file mode 100644 index 00000000..12966c66 --- /dev/null +++ b/frontend/src/interfaces/NewWorkOrder.ts @@ -0,0 +1,7 @@ +//Just the bare minimum to create a new work order +//No work order ID since it is generated by the backend +export interface NewWorkOrder { + date_created: Date; //This should be on the frontend to ensure it doesn't reflect server time + meter_id: number; + title: string; +} diff --git a/frontend/src/interfaces/NoteTypeLU.ts b/frontend/src/interfaces/NoteTypeLU.ts new file mode 100644 index 00000000..2332536e --- /dev/null +++ b/frontend/src/interfaces/NoteTypeLU.ts @@ -0,0 +1,7 @@ +export interface NoteTypeLU { + id: number; + note: string; + details?: string; + slug?: string; + commonly_used: boolean; +} diff --git a/frontend/src/interfaces/Notification.ts b/frontend/src/interfaces/Notification.ts new file mode 100644 index 00000000..feaac5b5 --- /dev/null +++ b/frontend/src/interfaces/Notification.ts @@ -0,0 +1,17 @@ +import { NotificationType } from "./NotificationType"; +import { User } from "./User"; + +export interface Notification { + id: number; + user_id: number; + notification_type_id: number; + created_by?: number | null; + title: string; + message: string; + link?: string | null; + is_read: boolean; + created_at: string; + read_at?: string | null; + notification_type: NotificationType; + creator?: User | null; +} diff --git a/frontend/src/interfaces/NotificationCreateResult.ts b/frontend/src/interfaces/NotificationCreateResult.ts new file mode 100644 index 00000000..3bdd4fc2 --- /dev/null +++ b/frontend/src/interfaces/NotificationCreateResult.ts @@ -0,0 +1,3 @@ +export interface NotificationCreateResult { + created_count: number; +} diff --git a/frontend/src/interfaces/NotificationQueryParams.ts b/frontend/src/interfaces/NotificationQueryParams.ts new file mode 100644 index 00000000..d9176f2d --- /dev/null +++ b/frontend/src/interfaces/NotificationQueryParams.ts @@ -0,0 +1,9 @@ +export interface NotificationQueryParams { + q?: string; + is_read?: boolean; + notification_type_id?: number[]; + created_from?: string; + created_to?: string; + limit?: number; + offset?: number; +} diff --git a/frontend/src/interfaces/NotificationType.ts b/frontend/src/interfaces/NotificationType.ts new file mode 100644 index 00000000..ef2493d0 --- /dev/null +++ b/frontend/src/interfaces/NotificationType.ts @@ -0,0 +1,5 @@ +export interface NotificationType { + id: number; + name: string; + description?: string | null; +} diff --git a/frontend/src/interfaces/ObservationForm.ts b/frontend/src/interfaces/ObservationForm.ts new file mode 100644 index 00000000..f809814c --- /dev/null +++ b/frontend/src/interfaces/ObservationForm.ts @@ -0,0 +1,8 @@ +import type { Dayjs } from "dayjs"; + +export interface ObservationForm { + time: Dayjs; + reading: "" | number; + property_type_id: "" | number; + unit_id: "" | number; +} diff --git a/frontend/src/interfaces/ObservedPropertyTypeLU.ts b/frontend/src/interfaces/ObservedPropertyTypeLU.ts new file mode 100644 index 00000000..746a1539 --- /dev/null +++ b/frontend/src/interfaces/ObservedPropertyTypeLU.ts @@ -0,0 +1,10 @@ +import type { Unit } from "./Unit"; + +export interface ObservedPropertyTypeLU { + id: number; + name: string; + description: string; + context: string; + + units?: Unit[]; +} diff --git a/frontend/src/interfaces/Organization.ts b/frontend/src/interfaces/Organization.ts new file mode 100644 index 00000000..332471ce --- /dev/null +++ b/frontend/src/interfaces/Organization.ts @@ -0,0 +1,3 @@ +export interface Organization { + organization_name: string; +} diff --git a/frontend/src/interfaces/Page.ts b/frontend/src/interfaces/Page.ts new file mode 100644 index 00000000..f1b1946e --- /dev/null +++ b/frontend/src/interfaces/Page.ts @@ -0,0 +1,6 @@ +export interface Page { + items: T[]; + total: number; + limit: number; + offset: number; +} diff --git a/frontend/src/interfaces/Part.ts b/frontend/src/interfaces/Part.ts new file mode 100644 index 00000000..f2251520 --- /dev/null +++ b/frontend/src/interfaces/Part.ts @@ -0,0 +1,18 @@ +import type { MeterTypeLU } from "./MeterTypeLU"; +import type { PartTypeLU } from "./PartTypeLU"; + +export interface Part { + id: number; + part_number: string; + part_type_id: number; + vendor?: string; + note?: string; + description?: string; + initial_count?: number; + current_count?: number; + in_use: boolean; + commonly_used: boolean; + + part_type?: PartTypeLU; + meter_types?: MeterTypeLU[]; +} diff --git a/frontend/src/interfaces/PartAssociation.ts b/frontend/src/interfaces/PartAssociation.ts new file mode 100644 index 00000000..e81db279 --- /dev/null +++ b/frontend/src/interfaces/PartAssociation.ts @@ -0,0 +1,10 @@ +import type { int } from "./primitives"; +import type { Part } from "./Part"; + +export interface PartAssociation { + id: int; + meter_type_id: int; + part_id: int; + commonly_used: boolean; + part?: Part; +} diff --git a/frontend/src/interfaces/PartHistoryResponse.ts b/frontend/src/interfaces/PartHistoryResponse.ts new file mode 100644 index 00000000..1e9a4761 --- /dev/null +++ b/frontend/src/interfaces/PartHistoryResponse.ts @@ -0,0 +1,31 @@ +export type PartHistoryRow = { + row_id: string; + part_id: number; + event_date: string; + event_type: "initial" | "added" | "used"; + ref_id?: number | null; + work_order_id?: number | null; + note?: string | null; + delta: number; + total_after: number; +}; + +export type EditablePartHistoryRow = { + ref_id: number; + event_date: string; + event_type: "added" | "used"; + note?: string | null; + delta: number; +}; + +export type UpdatePartHistoryPayload = { + rows: EditablePartHistoryRow[]; +}; + +export type PartHistoryResponse = { + part_id: number; + part_number: string; + initial_count: number; + current_count: number; + history: PartHistoryRow[]; +}; diff --git a/frontend/src/interfaces/PartTypeLU.ts b/frontend/src/interfaces/PartTypeLU.ts new file mode 100644 index 00000000..b92a9cf2 --- /dev/null +++ b/frontend/src/interfaces/PartTypeLU.ts @@ -0,0 +1,7 @@ +import type { int } from "./primitives"; + +export interface PartTypeLU { + id: int; + name: string; + description?: string; +} diff --git a/frontend/src/interfaces/PatchActivityForm.ts b/frontend/src/interfaces/PatchActivityForm.ts new file mode 100644 index 00000000..8e89456e --- /dev/null +++ b/frontend/src/interfaces/PatchActivityForm.ts @@ -0,0 +1,29 @@ +import type { Dayjs } from "dayjs"; +import type { int } from "./primitives"; +import type { ActivityTypeLU } from "./ActivityTypeLU"; +import type { NoteTypeLU } from "./NoteTypeLU"; +import type { Part } from "./Part"; +import type { ServiceTypeLU } from "./ServiceTypeLU"; +import type { User } from "./User"; +import type { Well } from "./Well"; + +//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; + activity_start_time: Dayjs; + activity_end_time: Dayjs; + activity_type: ActivityTypeLU; + submitting_user: User; + description: string; + + well: Well | null; + water_users?: string; + + notes?: NoteTypeLU[]; + services?: ServiceTypeLU[]; + parts_used?: Part[]; + + ose_share: boolean; +} diff --git a/frontend/src/interfaces/PatchActivitySubmit.ts b/frontend/src/interfaces/PatchActivitySubmit.ts new file mode 100644 index 00000000..211019bc --- /dev/null +++ b/frontend/src/interfaces/PatchActivitySubmit.ts @@ -0,0 +1,19 @@ +import type { int } from "./primitives"; + +//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; +} diff --git a/frontend/src/interfaces/PatchObservationForm.ts b/frontend/src/interfaces/PatchObservationForm.ts new file mode 100644 index 00000000..dc5ada10 --- /dev/null +++ b/frontend/src/interfaces/PatchObservationForm.ts @@ -0,0 +1,21 @@ +import type { Dayjs } from "dayjs"; +import type { int } from "./primitives"; +import type { ObservedPropertyTypeLU } from "./ObservedPropertyTypeLU"; +import type { Unit } from "./Unit"; +import type { User } from "./User"; +import type { Well } from "./Well"; + +//Designed for the HistoryDetails component, not the patch endpoint +export interface PatchObservationForm { + observation_id: int; + submitting_user: User; + well: Well | null; + observation_date: Dayjs; + observation_time: Dayjs; + property_type: ObservedPropertyTypeLU; + unit: Unit; + value: number; + ose_share: boolean; + notes?: string; + meter_id: int; +} diff --git a/frontend/src/interfaces/PatchObservationSubmit.ts b/frontend/src/interfaces/PatchObservationSubmit.ts new file mode 100644 index 00000000..f2b24d7f --- /dev/null +++ b/frontend/src/interfaces/PatchObservationSubmit.ts @@ -0,0 +1,15 @@ +import type { int } from "./primitives"; + +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; +} diff --git a/frontend/src/interfaces/PatchRegionMeasurement.ts b/frontend/src/interfaces/PatchRegionMeasurement.ts new file mode 100644 index 00000000..9f9c8f68 --- /dev/null +++ b/frontend/src/interfaces/PatchRegionMeasurement.ts @@ -0,0 +1,9 @@ +import type { Dayjs } from "dayjs"; + +export interface PatchRegionMeasurement { + levelmeasurement_id: number; + submitting_user_id: number; + well_id: number; + timestamp: Dayjs; + value?: number | null; +} diff --git a/frontend/src/interfaces/PatchWellMeasurement.ts b/frontend/src/interfaces/PatchWellMeasurement.ts new file mode 100644 index 00000000..c567ec45 --- /dev/null +++ b/frontend/src/interfaces/PatchWellMeasurement.ts @@ -0,0 +1,8 @@ +import type { Dayjs } from "dayjs"; + +export interface PatchWellMeasurement { + levelmeasurement_id: number; + submitting_user_id: number; + timestamp: Dayjs; + value: number; +} diff --git a/frontend/src/interfaces/PatchWorkOrder.ts b/frontend/src/interfaces/PatchWorkOrder.ts new file mode 100644 index 00000000..92b6c959 --- /dev/null +++ b/frontend/src/interfaces/PatchWorkOrder.ts @@ -0,0 +1,9 @@ +// This is designed to match the backend API patch endpoint and is limited to the fields that can be updated +export interface PatchWorkOrder { + work_order_id: number; + title?: string; + description?: string; + status?: string; + notes?: string; + assigned_user_id?: number; +} diff --git a/frontend/src/interfaces/RegionMeasurementDTO.ts b/frontend/src/interfaces/RegionMeasurementDTO.ts new file mode 100644 index 00000000..5ddafc4c --- /dev/null +++ b/frontend/src/interfaces/RegionMeasurementDTO.ts @@ -0,0 +1,7 @@ +export interface RegionMeasurementDTO { + id: number; + timestamp: Date; + value: number; + submitting_user: { id: number; full_name: string }; + well: { id: number; ra_number: string }; +} diff --git a/frontend/src/interfaces/ReportAveragesResponse.ts b/frontend/src/interfaces/ReportAveragesResponse.ts new file mode 100644 index 00000000..45aa6fcc --- /dev/null +++ b/frontend/src/interfaces/ReportAveragesResponse.ts @@ -0,0 +1,15 @@ +export interface ReportAverageRow { + period_start: string; // ISO date string + avg_value: number | null; +} + +export type ReportPerWellAverageRow = ReportAverageRow & { + well_id: number; + ra_number: string; +}; + +export interface ReportAveragesResponse { + bucket: "month" | "year" | null; + per_well: ReportPerWellAverageRow[]; + all_wells: ReportAverageRow[]; +} diff --git a/frontend/src/interfaces/ST2Measurement.ts b/frontend/src/interfaces/ST2Measurement.ts new file mode 100644 index 00000000..9e020e0c --- /dev/null +++ b/frontend/src/interfaces/ST2Measurement.ts @@ -0,0 +1,6 @@ +// 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; +} diff --git a/frontend/src/interfaces/ST2Response.ts b/frontend/src/interfaces/ST2Response.ts new file mode 100644 index 00000000..689b51c9 --- /dev/null +++ b/frontend/src/interfaces/ST2Response.ts @@ -0,0 +1,5 @@ +// Whole response returned from a NM ST2 endpoint +export interface ST2Response { + "@iot.nextLink": string; + value: []; +} diff --git a/frontend/src/interfaces/ST2WaterLevelQueryParams.ts b/frontend/src/interfaces/ST2WaterLevelQueryParams.ts new file mode 100644 index 00000000..11c9f15b --- /dev/null +++ b/frontend/src/interfaces/ST2WaterLevelQueryParams.ts @@ -0,0 +1,5 @@ +export interface ST2WaterLevelQueryParams { + $filter: string; + $orderby: string; + datastreamID: number | undefined; +} diff --git a/frontend/src/interfaces/SecurityScope.ts b/frontend/src/interfaces/SecurityScope.ts new file mode 100644 index 00000000..cf94b789 --- /dev/null +++ b/frontend/src/interfaces/SecurityScope.ts @@ -0,0 +1,5 @@ +export interface SecurityScope { + id: number; + scope_string: string; + description: string; +} diff --git a/frontend/src/interfaces/ServiceTypeLU.ts b/frontend/src/interfaces/ServiceTypeLU.ts new file mode 100644 index 00000000..c1711eb5 --- /dev/null +++ b/frontend/src/interfaces/ServiceTypeLU.ts @@ -0,0 +1,5 @@ +export interface ServiceTypeLU { + id: number; + service_name: string; + description?: string; +} diff --git a/frontend/src/interfaces/SubmitWellCreate.ts b/frontend/src/interfaces/SubmitWellCreate.ts new file mode 100644 index 00000000..f34f8bf5 --- /dev/null +++ b/frontend/src/interfaces/SubmitWellCreate.ts @@ -0,0 +1,22 @@ +import type { float } from "./primitives"; +import type { WaterSource } from "./WaterSource"; + +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; + }; + + location: { + name: string; + trss: string; + longitude: float; + latitude: float; + }; +} diff --git a/frontend/src/interfaces/Unit.ts b/frontend/src/interfaces/Unit.ts new file mode 100644 index 00000000..376a7ed6 --- /dev/null +++ b/frontend/src/interfaces/Unit.ts @@ -0,0 +1,6 @@ +export interface Unit { + id: number; + name: string; + name_short: string; + description: string; +} diff --git a/frontend/src/interfaces/UpdatedUserPassword.ts b/frontend/src/interfaces/UpdatedUserPassword.ts new file mode 100644 index 00000000..2579265e --- /dev/null +++ b/frontend/src/interfaces/UpdatedUserPassword.ts @@ -0,0 +1,4 @@ +export interface UpdatedUserPassword { + user_id: number; + new_password: string; +} diff --git a/frontend/src/interfaces/User.ts b/frontend/src/interfaces/User.ts new file mode 100644 index 00000000..de14c955 --- /dev/null +++ b/frontend/src/interfaces/User.ts @@ -0,0 +1,16 @@ +import type { scope_string } from "./primitives"; +import type { UserRole } from "./UserRole"; + +export interface User { + id: number; + username?: string; + full_name: string; + display_name?: string; + email?: scope_string; + disabled: boolean; + user_role_id?: number; + user_role?: UserRole; + redirect_page?: string; + avatar_img?: string | null; + password?: string; +} diff --git a/frontend/src/interfaces/UserRole.ts b/frontend/src/interfaces/UserRole.ts new file mode 100644 index 00000000..eedfae03 --- /dev/null +++ b/frontend/src/interfaces/UserRole.ts @@ -0,0 +1,7 @@ +import type { SecurityScope } from "./SecurityScope"; + +export interface UserRole { + id: number; + name: string; + security_scopes: SecurityScope[]; +} diff --git a/frontend/src/interfaces/WaterLevelQueryParams.ts b/frontend/src/interfaces/WaterLevelQueryParams.ts new file mode 100644 index 00000000..dfa8853d --- /dev/null +++ b/frontend/src/interfaces/WaterLevelQueryParams.ts @@ -0,0 +1,3 @@ +export interface WaterLevelQueryParams { + well_id: number | undefined; +} diff --git a/frontend/src/interfaces/WaterSource.ts b/frontend/src/interfaces/WaterSource.ts new file mode 100644 index 00000000..c31645ec --- /dev/null +++ b/frontend/src/interfaces/WaterSource.ts @@ -0,0 +1,5 @@ +export interface WaterSource { + id: number; + name: string; + description: string; +} diff --git a/frontend/src/interfaces/Well.ts b/frontend/src/interfaces/Well.ts new file mode 100644 index 00000000..f2f29e42 --- /dev/null +++ b/frontend/src/interfaces/Well.ts @@ -0,0 +1,21 @@ +import type { int } from "./primitives"; +import type { BaseWell } from "./BaseWell"; +import type { Location } from "./Location"; +import type { WaterSource } from "./WaterSource"; +import type { WellStatus } from "./WellStatus"; +import type { WellUseLU } from "./WellUseLU"; + +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; + } + ]; +} diff --git a/frontend/src/interfaces/WellDetailsQueryParams.ts b/frontend/src/interfaces/WellDetailsQueryParams.ts new file mode 100644 index 00000000..b1cc5caf --- /dev/null +++ b/frontend/src/interfaces/WellDetailsQueryParams.ts @@ -0,0 +1,3 @@ +export interface WellDetailsQueryParams { + well_id: number | undefined; +} diff --git a/frontend/src/interfaces/WellListQueryParams.ts b/frontend/src/interfaces/WellListQueryParams.ts new file mode 100644 index 00000000..433c95ba --- /dev/null +++ b/frontend/src/interfaces/WellListQueryParams.ts @@ -0,0 +1,10 @@ +import type { SortDirection } from "@/enums"; + +export interface WellListQueryParams { + search_string?: string; + // sort_by?: WellSortByField + sort_direction?: SortDirection; + limit?: number; + offset?: number; + exclude_inactive?: boolean; +} diff --git a/frontend/src/interfaces/WellMeasurementDTO.ts b/frontend/src/interfaces/WellMeasurementDTO.ts new file mode 100644 index 00000000..10978769 --- /dev/null +++ b/frontend/src/interfaces/WellMeasurementDTO.ts @@ -0,0 +1,8 @@ +// Single manual measurement from a certain well +export interface WellMeasurementDTO { + id: number; + timestamp: Date; + value: number; + submitting_user: { full_name: string }; + well: { id: number; ra_number: string }; +} diff --git a/frontend/src/interfaces/WellMergeParams.ts b/frontend/src/interfaces/WellMergeParams.ts new file mode 100644 index 00000000..d294bc80 --- /dev/null +++ b/frontend/src/interfaces/WellMergeParams.ts @@ -0,0 +1,4 @@ +export interface WellMergeParams { + merge_well: string; + target_well: string; +} diff --git a/frontend/src/interfaces/WellStatus.ts b/frontend/src/interfaces/WellStatus.ts new file mode 100644 index 00000000..18453d46 --- /dev/null +++ b/frontend/src/interfaces/WellStatus.ts @@ -0,0 +1,5 @@ +export interface WellStatus { + id: number; + status: string; + description: string; +} diff --git a/frontend/src/interfaces/WellUpdate.ts b/frontend/src/interfaces/WellUpdate.ts new file mode 100644 index 00000000..254e08f8 --- /dev/null +++ b/frontend/src/interfaces/WellUpdate.ts @@ -0,0 +1,12 @@ +import type { BaseWell } from "./BaseWell"; +import type { Location } from "./Location"; +import type { WaterSource } from "./WaterSource"; +import type { WellStatus } from "./WellStatus"; +import type { WellUseLU } from "./WellUseLU"; + +export interface WellUpdate extends BaseWell { + use_type: WellUseLU; + water_source: WaterSource; + location: Location; + well_status: WellStatus; +} diff --git a/frontend/src/interfaces/WellUseLU.ts b/frontend/src/interfaces/WellUseLU.ts new file mode 100644 index 00000000..bbf0abef --- /dev/null +++ b/frontend/src/interfaces/WellUseLU.ts @@ -0,0 +1,6 @@ +export interface WellUseLU { + id: number; + use_type: string; + code: string; + description: string; +} diff --git a/frontend/src/interfaces/WorkOrder.ts b/frontend/src/interfaces/WorkOrder.ts new file mode 100644 index 00000000..1fe3f1a1 --- /dev/null +++ b/frontend/src/interfaces/WorkOrder.ts @@ -0,0 +1,13 @@ +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[]; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 85c72576..b671acd0 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -1,6 +1,80 @@ +export * from "./ActivityForm"; +export * from "./ActivityFormControl"; +export * from "./ActivityTypeLU"; export * from "./BackupRow"; +export * from "./BaseWell"; +export * from "./CreateUser"; +export * from "./CreateNotificationPayload"; export * from "./DeviceAttributes"; export * from "./DevicePayload"; +export * from "./HomeSummary"; +export * from "./IncreaseQuantityPayload"; +export * from "./LandOwner"; +export * from "./Location"; export * from "./Measurement"; +export * from "./Meter"; +export * from "./MeterActivity"; +export * from "./MeterDetails"; +export * from "./MeterDetailsQueryParams"; +export * from "./MeterHistoryDTO"; +export * from "./MeterListDTO"; +export * from "./MeterListQuery"; +export * from "./MeterListQueryParams"; +export * from "./MeterListSortBy"; +export * from "./MeterMapDTO"; +export * from "./MeterPartParams"; +export * from "./MeterRegister"; +export * from "./MeterStatus"; +export * from "./MeterType"; +export * from "./MeterTypeLU"; +export * from "./MonitoredRegion"; +export * from "./MonitoredWell"; +export * from "./NewRegionMeasurement"; +export * from "./NewUser"; +export * from "./NewWellMeasurement"; +export * from "./NewWorkOrder"; +export * from "./Notification"; +export * from "./NotificationCreateResult"; +export * from "./NotificationQueryParams"; +export * from "./NotificationType"; +export * from "./NoteTypeLU"; +export * from "./ObservationForm"; +export * from "./ObservedPropertyTypeLU"; +export * from "./Organization"; +export * from "./Page"; +export * from "./Part"; +export * from "./PartAssociation"; +export * from "./PartTypeLU"; +export * from "./PatchActivityForm"; +export * from "./PatchActivitySubmit"; +export * from "./PatchObservationForm"; +export * from "./PatchObservationSubmit"; +export * from "./PatchRegionMeasurement"; +export * from "./PatchWellMeasurement"; +export * from "./PatchWorkOrder"; +export * from "./RegionMeasurementDTO"; +export * from "./ReportAveragesResponse"; +export * from "./ST2Measurement"; +export * from "./ST2Response"; +export * from "./ST2WaterLevelQueryParams"; +export * from "./SecurityScope"; export * from "./SensorAttributes"; export * from "./SensorData"; +export * from "./ServiceTypeLU"; +export * from "./SubmitWellCreate"; +export * from "./Unit"; +export * from "./UpdatedUserPassword"; +export * from "./User"; +export * from "./UserRole"; +export * from "./WaterLevelQueryParams"; +export * from "./WaterSource"; +export * from "./Well"; +export * from "./WellDetailsQueryParams"; +export * from "./WellListQueryParams"; +export * from "./WellMeasurementDTO"; +export * from "./WellMergeParams"; +export * from "./WellStatus"; +export * from "./WellUpdate"; +export * from "./WellUseLU"; +export * from "./WorkOrder"; +export * from "./primitives"; diff --git a/frontend/src/interfaces/primitives.ts b/frontend/src/interfaces/primitives.ts new file mode 100644 index 00000000..badf9893 --- /dev/null +++ b/frontend/src/interfaces/primitives.ts @@ -0,0 +1,3 @@ +export type int = number; +export type float = number; +export type scope_string = string; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 00000000..8e4d6621 --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,545 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as WorkordersRouteImport } from './routes/workorders' +import { Route as SettingsRouteImport } from './routes/settings' +import { Route as NotificationsRouteImport } from './routes/notifications' +import { Route as MonitoringwellsRouteImport } from './routes/monitoringwells' +import { Route as LoginRouteImport } from './routes/login' +import { Route as ChloridesRouteImport } from './routes/chlorides' +import { Route as ActivitiesRouteImport } from './routes/activities' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ReportsIndexRouteImport } from './routes/reports/index' +import { Route as ManageIndexRouteImport } from './routes/manage/index' +import { Route as ReportsPartsusedRouteImport } from './routes/reports/partsused' +import { Route as ReportsMonitoringwellsRouteImport } from './routes/reports/monitoringwells' +import { Route as ReportsMaintenanceRouteImport } from './routes/reports/maintenance' +import { Route as ReportsChloridesRouteImport } from './routes/reports/chlorides' +import { Route as ManageWellsRouteImport } from './routes/manage/wells' +import { Route as ManageUsersRouteImport } from './routes/manage/users' +import { Route as ManagePartsRouteImport } from './routes/manage/parts' +import { Route as ManageMetersRouteImport } from './routes/manage/meters' +import { Route as ManageBackupsRouteImport } from './routes/manage/backups' +import { Route as InternalErrorPreviewRouteImport } from './routes/internal/error-preview' +import { Route as ManagePartsIndexRouteImport } from './routes/manage/parts/index' +import { Route as ManagePartsIdHistoryRouteImport } from './routes/manage/parts/$id/history' +import { Route as ActivitiesActivity_idPhotosPhoto_file_nameRouteImport } from './routes/activities/$activity_id/photos/$photo_file_name' + +const WorkordersRoute = WorkordersRouteImport.update({ + id: '/workorders', + path: '/workorders', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any) +const NotificationsRoute = NotificationsRouteImport.update({ + id: '/notifications', + path: '/notifications', + getParentRoute: () => rootRouteImport, +} as any) +const MonitoringwellsRoute = MonitoringwellsRouteImport.update({ + id: '/monitoringwells', + path: '/monitoringwells', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const ChloridesRoute = ChloridesRouteImport.update({ + id: '/chlorides', + path: '/chlorides', + getParentRoute: () => rootRouteImport, +} as any) +const ActivitiesRoute = ActivitiesRouteImport.update({ + id: '/activities', + path: '/activities', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ReportsIndexRoute = ReportsIndexRouteImport.update({ + id: '/reports/', + path: '/reports/', + getParentRoute: () => rootRouteImport, +} as any) +const ManageIndexRoute = ManageIndexRouteImport.update({ + id: '/manage/', + path: '/manage/', + getParentRoute: () => rootRouteImport, +} as any) +const ReportsPartsusedRoute = ReportsPartsusedRouteImport.update({ + id: '/reports/partsused', + path: '/reports/partsused', + getParentRoute: () => rootRouteImport, +} as any) +const ReportsMonitoringwellsRoute = ReportsMonitoringwellsRouteImport.update({ + id: '/reports/monitoringwells', + path: '/reports/monitoringwells', + getParentRoute: () => rootRouteImport, +} as any) +const ReportsMaintenanceRoute = ReportsMaintenanceRouteImport.update({ + id: '/reports/maintenance', + path: '/reports/maintenance', + getParentRoute: () => rootRouteImport, +} as any) +const ReportsChloridesRoute = ReportsChloridesRouteImport.update({ + id: '/reports/chlorides', + path: '/reports/chlorides', + getParentRoute: () => rootRouteImport, +} as any) +const ManageWellsRoute = ManageWellsRouteImport.update({ + id: '/manage/wells', + path: '/manage/wells', + getParentRoute: () => rootRouteImport, +} as any) +const ManageUsersRoute = ManageUsersRouteImport.update({ + id: '/manage/users', + path: '/manage/users', + getParentRoute: () => rootRouteImport, +} as any) +const ManagePartsRoute = ManagePartsRouteImport.update({ + id: '/manage/parts', + path: '/manage/parts', + getParentRoute: () => rootRouteImport, +} as any) +const ManageMetersRoute = ManageMetersRouteImport.update({ + id: '/manage/meters', + path: '/manage/meters', + getParentRoute: () => rootRouteImport, +} as any) +const ManageBackupsRoute = ManageBackupsRouteImport.update({ + id: '/manage/backups', + path: '/manage/backups', + getParentRoute: () => rootRouteImport, +} as any) +const InternalErrorPreviewRoute = InternalErrorPreviewRouteImport.update({ + id: '/internal/error-preview', + path: '/internal/error-preview', + getParentRoute: () => rootRouteImport, +} as any) +const ManagePartsIndexRoute = ManagePartsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ManagePartsRoute, +} as any) +const ManagePartsIdHistoryRoute = ManagePartsIdHistoryRouteImport.update({ + id: '/$id/history', + path: '/$id/history', + getParentRoute: () => ManagePartsRoute, +} as any) +const ActivitiesActivity_idPhotosPhoto_file_nameRoute = + ActivitiesActivity_idPhotosPhoto_file_nameRouteImport.update({ + id: '/$activity_id/photos/$photo_file_name', + path: '/$activity_id/photos/$photo_file_name', + getParentRoute: () => ActivitiesRoute, + } as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/activities': typeof ActivitiesRouteWithChildren + '/chlorides': typeof ChloridesRoute + '/login': typeof LoginRoute + '/monitoringwells': typeof MonitoringwellsRoute + '/notifications': typeof NotificationsRoute + '/settings': typeof SettingsRoute + '/workorders': typeof WorkordersRoute + '/internal/error-preview': typeof InternalErrorPreviewRoute + '/manage/backups': typeof ManageBackupsRoute + '/manage/meters': typeof ManageMetersRoute + '/manage/parts': typeof ManagePartsRouteWithChildren + '/manage/users': typeof ManageUsersRoute + '/manage/wells': typeof ManageWellsRoute + '/reports/chlorides': typeof ReportsChloridesRoute + '/reports/maintenance': typeof ReportsMaintenanceRoute + '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute + '/reports/partsused': typeof ReportsPartsusedRoute + '/manage/': typeof ManageIndexRoute + '/reports/': typeof ReportsIndexRoute + '/manage/parts/': typeof ManagePartsIndexRoute + '/activities/$activity_id/photos/$photo_file_name': typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute + '/manage/parts/$id/history': typeof ManagePartsIdHistoryRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/activities': typeof ActivitiesRouteWithChildren + '/chlorides': typeof ChloridesRoute + '/login': typeof LoginRoute + '/monitoringwells': typeof MonitoringwellsRoute + '/notifications': typeof NotificationsRoute + '/settings': typeof SettingsRoute + '/workorders': typeof WorkordersRoute + '/internal/error-preview': typeof InternalErrorPreviewRoute + '/manage/backups': typeof ManageBackupsRoute + '/manage/meters': typeof ManageMetersRoute + '/manage/users': typeof ManageUsersRoute + '/manage/wells': typeof ManageWellsRoute + '/reports/chlorides': typeof ReportsChloridesRoute + '/reports/maintenance': typeof ReportsMaintenanceRoute + '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute + '/reports/partsused': typeof ReportsPartsusedRoute + '/manage': typeof ManageIndexRoute + '/reports': typeof ReportsIndexRoute + '/manage/parts': typeof ManagePartsIndexRoute + '/activities/$activity_id/photos/$photo_file_name': typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute + '/manage/parts/$id/history': typeof ManagePartsIdHistoryRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/activities': typeof ActivitiesRouteWithChildren + '/chlorides': typeof ChloridesRoute + '/login': typeof LoginRoute + '/monitoringwells': typeof MonitoringwellsRoute + '/notifications': typeof NotificationsRoute + '/settings': typeof SettingsRoute + '/workorders': typeof WorkordersRoute + '/internal/error-preview': typeof InternalErrorPreviewRoute + '/manage/backups': typeof ManageBackupsRoute + '/manage/meters': typeof ManageMetersRoute + '/manage/parts': typeof ManagePartsRouteWithChildren + '/manage/users': typeof ManageUsersRoute + '/manage/wells': typeof ManageWellsRoute + '/reports/chlorides': typeof ReportsChloridesRoute + '/reports/maintenance': typeof ReportsMaintenanceRoute + '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute + '/reports/partsused': typeof ReportsPartsusedRoute + '/manage/': typeof ManageIndexRoute + '/reports/': typeof ReportsIndexRoute + '/manage/parts/': typeof ManagePartsIndexRoute + '/activities/$activity_id/photos/$photo_file_name': typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute + '/manage/parts/$id/history': typeof ManagePartsIdHistoryRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/activities' + | '/chlorides' + | '/login' + | '/monitoringwells' + | '/notifications' + | '/settings' + | '/workorders' + | '/internal/error-preview' + | '/manage/backups' + | '/manage/meters' + | '/manage/parts' + | '/manage/users' + | '/manage/wells' + | '/reports/chlorides' + | '/reports/maintenance' + | '/reports/monitoringwells' + | '/reports/partsused' + | '/manage/' + | '/reports/' + | '/manage/parts/' + | '/activities/$activity_id/photos/$photo_file_name' + | '/manage/parts/$id/history' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/activities' + | '/chlorides' + | '/login' + | '/monitoringwells' + | '/notifications' + | '/settings' + | '/workorders' + | '/internal/error-preview' + | '/manage/backups' + | '/manage/meters' + | '/manage/users' + | '/manage/wells' + | '/reports/chlorides' + | '/reports/maintenance' + | '/reports/monitoringwells' + | '/reports/partsused' + | '/manage' + | '/reports' + | '/manage/parts' + | '/activities/$activity_id/photos/$photo_file_name' + | '/manage/parts/$id/history' + id: + | '__root__' + | '/' + | '/activities' + | '/chlorides' + | '/login' + | '/monitoringwells' + | '/notifications' + | '/settings' + | '/workorders' + | '/internal/error-preview' + | '/manage/backups' + | '/manage/meters' + | '/manage/parts' + | '/manage/users' + | '/manage/wells' + | '/reports/chlorides' + | '/reports/maintenance' + | '/reports/monitoringwells' + | '/reports/partsused' + | '/manage/' + | '/reports/' + | '/manage/parts/' + | '/activities/$activity_id/photos/$photo_file_name' + | '/manage/parts/$id/history' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ActivitiesRoute: typeof ActivitiesRouteWithChildren + ChloridesRoute: typeof ChloridesRoute + LoginRoute: typeof LoginRoute + MonitoringwellsRoute: typeof MonitoringwellsRoute + NotificationsRoute: typeof NotificationsRoute + SettingsRoute: typeof SettingsRoute + WorkordersRoute: typeof WorkordersRoute + InternalErrorPreviewRoute: typeof InternalErrorPreviewRoute + ManageBackupsRoute: typeof ManageBackupsRoute + ManageMetersRoute: typeof ManageMetersRoute + ManagePartsRoute: typeof ManagePartsRouteWithChildren + ManageUsersRoute: typeof ManageUsersRoute + ManageWellsRoute: typeof ManageWellsRoute + ReportsChloridesRoute: typeof ReportsChloridesRoute + ReportsMaintenanceRoute: typeof ReportsMaintenanceRoute + ReportsMonitoringwellsRoute: typeof ReportsMonitoringwellsRoute + ReportsPartsusedRoute: typeof ReportsPartsusedRoute + ManageIndexRoute: typeof ManageIndexRoute + ReportsIndexRoute: typeof ReportsIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/workorders': { + id: '/workorders' + path: '/workorders' + fullPath: '/workorders' + preLoaderRoute: typeof WorkordersRouteImport + parentRoute: typeof rootRouteImport + } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/notifications': { + id: '/notifications' + path: '/notifications' + fullPath: '/notifications' + preLoaderRoute: typeof NotificationsRouteImport + parentRoute: typeof rootRouteImport + } + '/monitoringwells': { + id: '/monitoringwells' + path: '/monitoringwells' + fullPath: '/monitoringwells' + preLoaderRoute: typeof MonitoringwellsRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/chlorides': { + id: '/chlorides' + path: '/chlorides' + fullPath: '/chlorides' + preLoaderRoute: typeof ChloridesRouteImport + parentRoute: typeof rootRouteImport + } + '/activities': { + id: '/activities' + path: '/activities' + fullPath: '/activities' + preLoaderRoute: typeof ActivitiesRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/reports/': { + id: '/reports/' + path: '/reports' + fullPath: '/reports/' + preLoaderRoute: typeof ReportsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/': { + id: '/manage/' + path: '/manage' + fullPath: '/manage/' + preLoaderRoute: typeof ManageIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/reports/partsused': { + id: '/reports/partsused' + path: '/reports/partsused' + fullPath: '/reports/partsused' + preLoaderRoute: typeof ReportsPartsusedRouteImport + parentRoute: typeof rootRouteImport + } + '/reports/monitoringwells': { + id: '/reports/monitoringwells' + path: '/reports/monitoringwells' + fullPath: '/reports/monitoringwells' + preLoaderRoute: typeof ReportsMonitoringwellsRouteImport + parentRoute: typeof rootRouteImport + } + '/reports/maintenance': { + id: '/reports/maintenance' + path: '/reports/maintenance' + fullPath: '/reports/maintenance' + preLoaderRoute: typeof ReportsMaintenanceRouteImport + parentRoute: typeof rootRouteImport + } + '/reports/chlorides': { + id: '/reports/chlorides' + path: '/reports/chlorides' + fullPath: '/reports/chlorides' + preLoaderRoute: typeof ReportsChloridesRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/wells': { + id: '/manage/wells' + path: '/manage/wells' + fullPath: '/manage/wells' + preLoaderRoute: typeof ManageWellsRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/users': { + id: '/manage/users' + path: '/manage/users' + fullPath: '/manage/users' + preLoaderRoute: typeof ManageUsersRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/parts': { + id: '/manage/parts' + path: '/manage/parts' + fullPath: '/manage/parts' + preLoaderRoute: typeof ManagePartsRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/meters': { + id: '/manage/meters' + path: '/manage/meters' + fullPath: '/manage/meters' + preLoaderRoute: typeof ManageMetersRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/backups': { + id: '/manage/backups' + path: '/manage/backups' + fullPath: '/manage/backups' + preLoaderRoute: typeof ManageBackupsRouteImport + parentRoute: typeof rootRouteImport + } + '/internal/error-preview': { + id: '/internal/error-preview' + path: '/internal/error-preview' + fullPath: '/internal/error-preview' + preLoaderRoute: typeof InternalErrorPreviewRouteImport + parentRoute: typeof rootRouteImport + } + '/manage/parts/': { + id: '/manage/parts/' + path: '/' + fullPath: '/manage/parts/' + preLoaderRoute: typeof ManagePartsIndexRouteImport + parentRoute: typeof ManagePartsRoute + } + '/manage/parts/$id/history': { + id: '/manage/parts/$id/history' + path: '/$id/history' + fullPath: '/manage/parts/$id/history' + preLoaderRoute: typeof ManagePartsIdHistoryRouteImport + parentRoute: typeof ManagePartsRoute + } + '/activities/$activity_id/photos/$photo_file_name': { + id: '/activities/$activity_id/photos/$photo_file_name' + path: '/$activity_id/photos/$photo_file_name' + fullPath: '/activities/$activity_id/photos/$photo_file_name' + preLoaderRoute: typeof ActivitiesActivity_idPhotosPhoto_file_nameRouteImport + parentRoute: typeof ActivitiesRoute + } + } +} + +interface ActivitiesRouteChildren { + ActivitiesActivity_idPhotosPhoto_file_nameRoute: typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute +} + +const ActivitiesRouteChildren: ActivitiesRouteChildren = { + ActivitiesActivity_idPhotosPhoto_file_nameRoute: + ActivitiesActivity_idPhotosPhoto_file_nameRoute, +} + +const ActivitiesRouteWithChildren = ActivitiesRoute._addFileChildren( + ActivitiesRouteChildren, +) + +interface ManagePartsRouteChildren { + ManagePartsIndexRoute: typeof ManagePartsIndexRoute + ManagePartsIdHistoryRoute: typeof ManagePartsIdHistoryRoute +} + +const ManagePartsRouteChildren: ManagePartsRouteChildren = { + ManagePartsIndexRoute: ManagePartsIndexRoute, + ManagePartsIdHistoryRoute: ManagePartsIdHistoryRoute, +} + +const ManagePartsRouteWithChildren = ManagePartsRoute._addFileChildren( + ManagePartsRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ActivitiesRoute: ActivitiesRouteWithChildren, + ChloridesRoute: ChloridesRoute, + LoginRoute: LoginRoute, + MonitoringwellsRoute: MonitoringwellsRoute, + NotificationsRoute: NotificationsRoute, + SettingsRoute: SettingsRoute, + WorkordersRoute: WorkordersRoute, + InternalErrorPreviewRoute: InternalErrorPreviewRoute, + ManageBackupsRoute: ManageBackupsRoute, + ManageMetersRoute: ManageMetersRoute, + ManagePartsRoute: ManagePartsRouteWithChildren, + ManageUsersRoute: ManageUsersRoute, + ManageWellsRoute: ManageWellsRoute, + ReportsChloridesRoute: ReportsChloridesRoute, + ReportsMaintenanceRoute: ReportsMaintenanceRoute, + ReportsMonitoringwellsRoute: ReportsMonitoringwellsRoute, + ReportsPartsusedRoute: ReportsPartsusedRoute, + ManageIndexRoute: ManageIndexRoute, + ReportsIndexRoute: ReportsIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 00000000..464a1a94 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,16 @@ +import { createRouter } from "@tanstack/react-router"; +import type { QueryClient } from "react-query"; +import { routeTree } from "./routeTree.gen"; + +export const router = createRouter({ + routeTree, + context: { + queryClient: undefined as unknown as QueryClient, + }, +}); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 00000000..4ca06094 --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,24 @@ +import { + createRootRouteWithContext, + ErrorComponentProps, + Outlet, +} from "@tanstack/react-router"; +import type { QueryClient } from "react-query"; +import { AppLayout } from "@/AppLayout"; +import { NotFound, RouteErrorView } from "@/views"; + +const RootErrorComponent = ({ error, reset }: ErrorComponentProps) => { + return ; +}; + +export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()( + { + component: () => ( + + + + ), + errorComponent: RootErrorComponent, + notFoundComponent: NotFound, + }, +); diff --git a/frontend/src/routes/activities.tsx b/frontend/src/routes/activities.tsx new file mode 100644 index 00000000..c32dbb59 --- /dev/null +++ b/frontend/src/routes/activities.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { ActivitiesView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + optionalPositiveInt, + optionalTrimmedString, + routeSearchHydrator, +} from "@/utils"; + +const searchSchema = z.object({ + meter_id: optionalPositiveInt.catch(undefined).default(undefined), + serial_number: optionalTrimmedString.catch("").default(""), + work_order_id: optionalPositiveInt.catch(undefined).default(undefined), +}); + +export const Route = createFileRoute("/activities")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/activities/$activity_id/photos/$photo_file_name.tsx b/frontend/src/routes/activities/$activity_id/photos/$photo_file_name.tsx new file mode 100644 index 00000000..3f8fe1e1 --- /dev/null +++ b/frontend/src/routes/activities/$activity_id/photos/$photo_file_name.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ActivityPhotoView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute( + "/activities/$activity_id/photos/$photo_file_name", +)({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/routes/chlorides.tsx similarity index 57% rename from frontend/src/views/Chlorides/ChloridesView.tsx rename to frontend/src/routes/chlorides.tsx index 0b4d4e3b..22955d28 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/routes/chlorides.tsx @@ -1,4 +1,5 @@ -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { FormControl, Select, @@ -9,35 +10,64 @@ import { Alert, Button, AlertTitle, - Grid, } from "@mui/material"; +import { Science } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; import { useSnackbar } from "notistack"; -import { ChloridesTable } from "./ChloridesTable"; -import { ChloridesPlot } from "./ChloridesPlot"; -import { - CreateModal, - UpdateModal, -} from "../../components/Modals/Region"; +import dayjs, { Dayjs } from "dayjs"; +import { z } from "zod"; + +import { CreateModal, UpdateModal } from "@/components/Modals/Region"; import { NewRegionMeasurement, PatchRegionMeasurement, SecurityScope, RegionMeasurementDTO, -} from "../../interfaces"; -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"; -import { emptyToNull } from "../../utils"; +} from "@/interfaces"; +import { useFetchWithAuth } from "@/hooks"; +import { + BackgroundBox, + CustomCardHeader, + ResizableSplitPanels, +} from "@/components"; +import { + emptyToNull, + optionalPositiveInt, + pageParam, + routeSearchHydrator, +} from "@/utils"; +import { Table, Plot } from "@/views/Chlorides"; -export const ChloridesView = () => { +const searchSchema = z.object({ + regionId: optionalPositiveInt.catch(undefined).default(undefined), + page: pageParam(0, 0), + pageSize: pageParam(25, 10), + split: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const n = Number(val); + return Number.isInteger(n) && n >= 35 && n <= 72 ? n : undefined; + }, z.number().int().min(35).max(72).optional()) + .catch(undefined) + .default(undefined), +}); + +const CHLORIDES_SPLIT_STORAGE_KEY = "chlorides-split-width"; + +export const Route = createFileRoute("/chlorides")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: Chlorides, +}); + +function Chlorides() { + const navigate = useNavigate(); + const { regionId, split } = Route.useSearch(); const { enqueueSnackbar } = useSnackbar(); const fetchWithAuth = useFetchWithAuth(); - const selectedRegionId = useId(); - const [regionId, setregionId] = useState(); + const uniqueSelectId = useId(); const [selectedMeasurement, setSelectedMeasurement] = useState({ levelmeasurement_id: 0, @@ -50,17 +80,43 @@ export const ChloridesView = () => { const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + useEffect(() => { + if (split !== undefined) { + return; + } + + const storedSplit = window.localStorage.getItem( + CHLORIDES_SPLIT_STORAGE_KEY, + ); + if (!storedSplit) { + return; + } + + const parsedSplit = Number(storedSplit); + if ( + !Number.isInteger(parsedSplit) || + parsedSplit < 35 || + parsedSplit > 72 + ) { + return; + } + + navigate({ + to: "/chlorides", + search: (prev) => ({ + ...(prev as any), + split: parsedSplit, + }), + replace: true, + }); + }, [navigate, split]); + const authUser = useAuthUser(); const isAdmin = authUser()?.user_role.security_scopes.some( (s: SecurityScope) => s.scope_string === "admin", ); - const { - data: regions, - isLoading: isLoadingRegions, - error: errorRegions, - refetch: refetchRegions, - } = useQuery<{ id: number; names: string[] }[], Error>({ + const regionsQuery = useQuery<{ id: number; names: string[] }[], Error>({ queryKey: ["regions"], queryFn: () => fetchWithAuth({ @@ -72,12 +128,7 @@ export const ChloridesView = () => { }), }); - const { - data: manualMeasurements, - isLoading: isLoadingManual, - error: errorManual, - refetch: refetchManual, - } = useQuery({ + const manualQuery = useQuery({ queryKey: ["chlorides", regionId], queryFn: () => fetchWithAuth({ @@ -91,7 +142,7 @@ export const ChloridesView = () => { const milligramPerLiterUnitId = 14; const { mutateAsync: createChlorideLevel } = useMutation({ mutationKey: ["regions", "creation"], - mutationFn: (body: NewRegionMeasurement) => + mutationFn: (body: Partial) => fetchWithAuth({ method: "POST", route: "/chlorides", @@ -105,12 +156,17 @@ export const ChloridesView = () => { }, }), onSuccess: () => { - enqueueSnackbar("Chloride measurement created successfully", { variant: "success" }); + enqueueSnackbar("Chloride measurement created successfully", { + variant: "success", + }); }, onError: (err: any) => { - enqueueSnackbar(`Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); }, }); @@ -131,12 +187,17 @@ export const ChloridesView = () => { }, }), onSuccess: () => { - enqueueSnackbar("Chloride measurement updated successfully", { variant: "success" }); + enqueueSnackbar("Chloride measurement updated successfully", { + variant: "success", + }); }, onError: (err: any) => { - enqueueSnackbar(`Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); }, }); @@ -149,28 +210,33 @@ export const ChloridesView = () => { params: { chloride_measurement_id: levelmeasurement_id }, }), onSuccess: () => { - enqueueSnackbar("Chloride measurement deleted successfully", { variant: "success" }); + enqueueSnackbar("Chloride measurement deleted successfully", { + variant: "success", + }); }, onError: (err: any) => { - enqueueSnackbar(`Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); }, }); - const error = errorRegions || errorManual; + const error = regionsQuery.isError || manualQuery.isError; - const handleSubmitNewMeasurement = (data: NewRegionMeasurement) => { + const handleSubmitNewMeasurement = (data: Partial) => { if (regionId) { data.region_id = regionId; - createChlorideLevel(data, { onSuccess: () => refetchManual() }); + createChlorideLevel(data, { onSuccess: () => manualQuery.refetch() }); } setIsNewModalOpen(false); }; const handleSubmitMeasurementUpdate = () => { updateChlorideLevel(selectedMeasurement, { - onSuccess: () => refetchManual(), + onSuccess: () => manualQuery.refetch(), }); setIsUpdateModalOpen(false); }; @@ -179,7 +245,7 @@ export const ChloridesView = () => { setIsUpdateModalOpen(false); if (window.confirm("Are you sure you want to delete this measurement?")) { deleteChlorideLevel(selectedMeasurement.levelmeasurement_id, { - onSuccess: () => refetchManual(), + onSuccess: () => manualQuery.refetch(), }); } }; @@ -222,34 +288,46 @@ export const ChloridesView = () => { variant="outlined" color="inherit" size="small" - onClick={() => refetchRegions()} + onClick={() => regionsQuery.refetch()} > Retry } > Error Loading Data - We couldn’t load chloride data. Please check your connection or try - again. + We couldn’t load chloride data. Please check your connection or + try again. )} - Region + Region - - - m.timestamp) ?? []} + { + const roundedSplit = Math.round(nextSplit); + window.localStorage.setItem( + CHLORIDES_SPLIT_STORAGE_KEY, + roundedSplit.toString(), + ); + navigate({ + to: "/chlorides", + search: (prev) => ({ + ...(prev as any), + split: roundedSplit, + }), + replace: true, + }); + }} + left={ + m.timestamp) ?? []} manual_vals={ - manualMeasurements?.map((m) => ({ + manualQuery?.data?.map((m) => ({ value: m.value, well: m.well.ra_number, })) ?? [] } /> - - - setIsNewModalOpen(true)} onMeasurementSelect={handleMeasurementSelect} /> - - + } + /> {authUser() && ( <> setIsNewModalOpen(false)} + open={isNewModalOpen} + onClose={() => setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} + open={isUpdateModalOpen} + onClose={() => setIsUpdateModalOpen(false)} measurement={selectedMeasurement} onUpdateMeasurement={(update) => setSelectedMeasurement({ ...selectedMeasurement, ...update }) @@ -310,4 +400,4 @@ export const ChloridesView = () => { ); -}; +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 00000000..f763b141 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Home } from "@/views"; + +export const Route = createFileRoute("/")({ + component: Home, +}); diff --git a/frontend/src/routes/internal/error-preview.tsx b/frontend/src/routes/internal/error-preview.tsx new file mode 100644 index 00000000..437e9fe5 --- /dev/null +++ b/frontend/src/routes/internal/error-preview.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/internal/error-preview")({ + component: ErrorPreviewComponent, +}); + +function ErrorPreviewComponent() { + throw new Error( + "Internal preview route crash. Use this page only to verify the styled router error screen and copied URL details.", + ); +} diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 00000000..0bc5f8c1 --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Login } from "@/views"; + +export const Route = createFileRoute("/login")({ + component: Login, +}); diff --git a/frontend/src/routes/manage/backups.tsx b/frontend/src/routes/manage/backups.tsx new file mode 100644 index 00000000..c889793c --- /dev/null +++ b/frontend/src/routes/manage/backups.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { BackupsView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { pageParam, routeSearchHydrator } from "@/utils"; + +const searchSchema = z.object({ + page: pageParam(0, 0), + pageSize: pageParam(25, 10), +}); + +export const Route = createFileRoute("/manage/backups")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/manage/index.tsx b/frontend/src/routes/manage/index.tsx new file mode 100644 index 00000000..9abb808a --- /dev/null +++ b/frontend/src/routes/manage/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ManageView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/manage/")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/manage/meters.tsx b/frontend/src/routes/manage/meters.tsx new file mode 100644 index 00000000..985a43fc --- /dev/null +++ b/frontend/src/routes/manage/meters.tsx @@ -0,0 +1,89 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { MetersView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + booleanParam, + mapBaseLayerSchema, + mapLatSchema, + mapLngSchema, + mapOverlayNamesSchema, + mapZoomSchema, + optionalPositiveInt, + optionalTrimmedString, + pageParam, + routeSearchHydrator, +} from "@/utils"; + +const meterFilterEnum = z.enum([ + "installed", + "stored", + "sold", + "scrapped", + "unknown", +]); + +const filtersSchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val : [val]; + const items = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter(Boolean); + + const allowed = new Set([ + "installed", + "stored", + "sold", + "scrapped", + "unknown", + ]); + const filtered = items.filter((x) => allowed.has(x)); + + return filtered.length ? filtered : undefined; + }, z.array(meterFilterEnum).optional()) + .default(["installed"]); + +const tabSchema = z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + return String(raw); + }, + z.enum(["list", "map"]).optional(), + ) + .catch("list"); + +const searchSchema = z.object({ + meter_id: optionalPositiveInt.catch(undefined).default(undefined), + activity_id: optionalPositiveInt.catch(undefined).default(undefined), + observation_id: optionalPositiveInt.catch(undefined).default(undefined), + add: booleanParam(true), + tab: tabSchema.default("list"), + q: optionalTrimmedString.catch("").default(""), + filters: filtersSchema, + // all meters list pagination + m_page: pageParam(0, 0), + m_pageSize: pageParam(25, 10), + // meter history pagination + h_page: pageParam(0, 0), + h_pageSize: pageParam(25, 10), + mapBase: mapBaseLayerSchema.catch("OpenStreetMap").default("OpenStreetMap"), + mapOverlays: mapOverlayNamesSchema, + mapLat: mapLatSchema, + mapLng: mapLngSchema, + mapZoom: mapZoomSchema, +}); + +export const Route = createFileRoute("/manage/meters")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/manage/parts.tsx b/frontend/src/routes/manage/parts.tsx new file mode 100644 index 00000000..ed8fb60f --- /dev/null +++ b/frontend/src/routes/manage/parts.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/manage/parts")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/manage/parts/$id/history.tsx b/frontend/src/routes/manage/parts/$id/history.tsx new file mode 100644 index 00000000..f440955b --- /dev/null +++ b/frontend/src/routes/manage/parts/$id/history.tsx @@ -0,0 +1,89 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import dayjs from "dayjs"; +import { PartsHistory } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { API_URL } from "@/config"; +import { + dayjsDateParam, + pageParam, +} from "@/utils"; +import { PartHistoryResponse } from "@/interfaces/PartHistoryResponse"; + +const eventTypeValues = ["initial", "used", "added", "current"] as const; +const defaultEventTypes: [ + (typeof eventTypeValues)[number], + ...(typeof eventTypeValues)[number][], +] = ["initial", "used", "added", "current"]; + +const eventTypesSchema = z + .preprocess((val) => { + if (val == null || val === "") return undefined; + + let rawValue = val; + if (typeof rawValue === "string") { + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) rawValue = parsed; + } catch { + // keep raw string and process as CSV + } + } + + const raw = Array.isArray(rawValue) ? rawValue : [rawValue]; + const values = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter( + (v): v is (typeof eventTypeValues)[number] => + eventTypeValues.includes(v as (typeof eventTypeValues)[number]), + ); + + const set = new Set(values); + return eventTypeValues.filter((type) => set.has(type)); + }, z.array(z.enum(eventTypeValues)).nonempty().optional()) + .catch(defaultEventTypes) + .default(defaultEventTypes); + +const searchSchema = z.object({ + from: dayjsDateParam.catch(undefined).default(undefined), + to: dayjsDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + type: eventTypesSchema, + q: z.string().catch("").default(""), + page: pageParam(0, 0), + pageSize: pageParam(25, 10), +}); + +export const Route = createFileRoute("/manage/parts/$id/history")({ + validateSearch: searchSchema, + loader: async ({ params, context }) => { + if (typeof window === "undefined") return null; + + const token = window.localStorage.getItem("_auth"); + if (!token) return null; + + await context.queryClient.prefetchQuery( + ["parts-history", params.id], + async () => { + const response = await fetch(`${API_URL}/parts/${params.id}/history`, { + headers: { Authorization: `bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(response.status.toString()); + } + + return (await response.json()) as PartHistoryResponse; + }, + ); + + return null; + }, + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/manage/parts/index.tsx b/frontend/src/routes/manage/parts/index.tsx new file mode 100644 index 00000000..61daef09 --- /dev/null +++ b/frontend/src/routes/manage/parts/index.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { PartsView } from "@/views"; +import { + booleanParam, + optionalPositiveInt, + pageParam, + triStateParam, +} from "@/utils"; + +const searchSchema = z.object({ + part_id: optionalPositiveInt.catch(undefined).default(undefined), + part_add: booleanParam(true), + part_q: z.string().catch("").default(""), + part_in_use: triStateParam("true"), + part_commonly_used: triStateParam("all"), + p_page: pageParam(0, 0), + p_pageSize: pageParam(25, 10), + + meter_type_id: optionalPositiveInt.catch(undefined).default(undefined), + meter_type_add: booleanParam(true), + meter_type_q: z.string().catch("").default(""), + meter_type_in_use: triStateParam("true"), + mt_page: pageParam(0, 0), + mt_pageSize: pageParam(25, 10), +}); + +export const Route = createFileRoute("/manage/parts/")({ + validateSearch: searchSchema, + component: PartsView, +}); diff --git a/frontend/src/routes/manage/users.tsx b/frontend/src/routes/manage/users.tsx new file mode 100644 index 00000000..3032e02c --- /dev/null +++ b/frontend/src/routes/manage/users.tsx @@ -0,0 +1,38 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { UserManagementView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + booleanParam, + optionalPositiveInt, + pageParam, + routeSearchHydrator, + triStateParam, +} from "@/utils"; + +const searchSchema = z.object({ + user_id: optionalPositiveInt.catch(undefined).default(undefined), + user_add: booleanParam(true), + user_q: z.string().catch("").default(""), + active: triStateParam("true"), + tech: triStateParam("all"), + u_page: pageParam(0, 0), + u_pageSize: pageParam(25, 10), + + role_id: optionalPositiveInt.catch(undefined).default(undefined), + role_add: booleanParam(true), + role_q: z.string().catch("").default(""), + r_page: pageParam(0, 0), + r_pageSize: pageParam(25, 10), +}); + +export const Route = createFileRoute("/manage/users")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/manage/wells.tsx b/frontend/src/routes/manage/wells.tsx new file mode 100644 index 00000000..aeaad7db --- /dev/null +++ b/frontend/src/routes/manage/wells.tsx @@ -0,0 +1,56 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { WellManagementView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + booleanParam, + mapBaseLayerSchema, + mapLatSchema, + mapLngSchema, + mapOverlayNamesSchema, + mapZoomSchema, + optionalPositiveInt, + optionalTrimmedString, + pageParam, + routeSearchHydrator, +} from "@/utils"; + +const tabSchema = z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const s = String(raw); + return s === "list" || s === "map" ? s : "list"; + }, + z.enum(["list", "map"]).optional(), + ) + .catch("list") + .default("list"); + +const searchSchema = z + .object({ + tab: tabSchema, + add: booleanParam(true), + q: optionalTrimmedString.catch("").default(""), + well_id: optionalPositiveInt.catch(undefined).default(undefined), + page: pageParam(0, 0), + pageSize: pageParam(25, 10), + mapBase: mapBaseLayerSchema.catch("OpenStreetMap").default("OpenStreetMap"), + mapOverlays: mapOverlayNamesSchema, + mapLat: mapLatSchema, + mapLng: mapLngSchema, + mapZoom: mapZoomSchema, + }) + .passthrough(); + +export const Route = createFileRoute("/manage/wells")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/routes/monitoringwells.tsx similarity index 69% rename from frontend/src/views/MonitoringWells/MonitoringWellsView.tsx rename to frontend/src/routes/monitoringwells.tsx index e89921ad..53c5b4e3 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/routes/monitoringwells.tsx @@ -1,4 +1,4 @@ -import { useId, useState, useMemo } from "react"; +import { useEffect, useId, useState, useMemo } from "react"; import { FormControl, Select, @@ -8,19 +8,17 @@ import { CardContent, ListSubheader, useTheme, - Grid, Alert, Button, AlertTitle, } from "@mui/material"; import { useQuery, useQueryClient } from "react-query"; import { useAuthUser } from "react-auth-kit"; -import { MonitoringWellsTable } from "./MonitoringWellsTable"; -import { MonitoringWellsPlot } from "./MonitoringWellsPlot"; -import { - CreateModal, - UpdateModal, -} from "../../components/Modals/MonitoredWell"; +import { enqueueSnackbar } from "notistack"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import dayjs, { Dayjs } from "dayjs"; +import { z } from "zod"; + import { NewWellMeasurement, PatchWellMeasurement, @@ -28,39 +26,99 @@ import { SecurityScope, WellMeasurementDTO, MonitoredWell, -} from "../../interfaces"; +} from "@/interfaces"; import { useCreateWaterLevel, useUpdateWaterLevel, useDeleteWaterLevel, -} from "../../service/ApiServiceNew"; -import dayjs, { Dayjs } from "dayjs"; -import { useFetchWithAuth, useFetchST2 } from "../../hooks"; -import { getDataStreamId } from "../../utils/DataStreamUtils"; +} from "@/service"; +import { useFetchWithAuth, useFetchST2 } from "@/hooks"; +import { getDataStreamId, separateAndSortMonitoredWells } from "@/utils"; import { MonitorHeart } from "@mui/icons-material"; -import { BackgroundBox } from "../../components/BackgroundBox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; -import { separateAndSortMonitoredWells } from "../../utils"; +import { CreateModal, UpdateModal } from "@/components/Modals/MonitoredWell"; +import { + CustomCardHeader, + BackgroundBox, + ResizableSplitPanels, +} from "@/components"; +import { Table, Plot } from "@/views/MonitoringWells"; +import { optionalPositiveInt, pageParam, routeSearchHydrator } from "@/utils"; + +const searchSchema = z.object({ + wellId: optionalPositiveInt.catch(undefined).default(undefined), + page: pageParam(0, 0), + pageSize: pageParam(25, 10), + split: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const n = Number(val); + return Number.isInteger(n) && n >= 35 && n <= 72 ? n : undefined; + }, z.number().int().min(35).max(72).optional()) + .catch(undefined) + .default(undefined), +}); + +const MONITORING_WELLS_SPLIT_STORAGE_KEY = "monitoringwells-split-width"; -export const MonitoringWellsView = () => { +export const Route = createFileRoute("/monitoringwells")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: MonitoringWells, +}); + +function MonitoringWells() { const theme = useTheme(); + const navigate = useNavigate(); + const { wellId, split } = Route.useSearch(); const queryClient = useQueryClient(); const fetchWithAuth = useFetchWithAuth(); const fetchSt2 = useFetchST2(); - const selectWellId = useId(); - const [wellId, setWellId] = useState(); - const [selectedMeasurement, setSelectedMeasurement] = - useState({ - levelmeasurement_id: 0, - timestamp: dayjs(), - value: 0, - submitting_user_id: 0, - }); + const uniqueSelectId = useId(); + const [selectedMeasurement, setSelectedMeasurement] = useState< + Partial + >({ + levelmeasurement_id: 0, + timestamp: dayjs(), + value: 0, + submitting_user_id: 0, + }); const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + useEffect(() => { + if (split !== undefined) { + return; + } + + const storedSplit = window.localStorage.getItem( + MONITORING_WELLS_SPLIT_STORAGE_KEY, + ); + if (!storedSplit) { + return; + } + + const parsedSplit = Number(storedSplit); + if ( + !Number.isInteger(parsedSplit) || + parsedSplit < 35 || + parsedSplit > 72 + ) { + return; + } + + navigate({ + to: "/monitoringwells", + search: (prev) => ({ + ...(prev as any), + split: parsedSplit, + }), + replace: true, + }); + }, [navigate, split]); + const authUser = useAuthUser(); const isAdmin = authUser()?.user_role.security_scopes.some( (s: SecurityScope) => s.scope_string === "admin", @@ -142,7 +200,7 @@ export const MonitoringWellsView = () => { errorSt2 || errorJohnsonSensorData; - const handleSubmitNewMeasurement = (data: NewWellMeasurement) => { + const handleSubmitNewMeasurement = (data: Partial) => { if (wellId) { data.well_id = wellId; createMeasurement.mutate(data, { @@ -170,12 +228,27 @@ export const MonitoringWellsView = () => { const handleDeleteMeasurement = () => { setIsUpdateModalOpen(false); + + const id = selectedMeasurement.levelmeasurement_id; + if (!id) { + enqueueSnackbar("No measurement selected to delete.", { + variant: "warning", + }); + return; + } + if (window.confirm("Are you sure you want to delete this measurement?")) { - deleteMeasurement.mutate(selectedMeasurement.levelmeasurement_id, { + deleteMeasurement.mutate(id, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["manualMeasurements", wellId], }); + enqueueSnackbar("Measurement deleted.", { variant: "success" }); + }, + onError: (e: any) => { + enqueueSnackbar(e?.message ?? "Failed to delete measurement.", { + variant: "error", + }); }, }); } @@ -208,7 +281,7 @@ export const MonitoringWellsView = () => { return ( - + {error && ( { monitoredWellsQuery?.isFetching || !!monitoredWellsQuery?.isError } > - Site + Site - - - { + const roundedSplit = Math.round(nextSplit); + window.localStorage.setItem( + MONITORING_WELLS_SPLIT_STORAGE_KEY, + roundedSplit.toString(), + ); + navigate({ + to: "/monitoringwells", + search: (prev) => ({ + ...(prev as any), + split: roundedSplit, + }), + replace: true, + }); + }} + left={ + { : undefined } /> - - - well.id == wellId, @@ -360,21 +460,21 @@ export const MonitoringWellsView = () => { onOpenModal={() => setIsNewModalOpen(true)} onMeasurementSelect={handleMeasurementSelect} /> - - + } + /> {authUser() && ( <> setIsNewModalOpen(false)} + open={isNewModalOpen} + onClose={() => setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} + open={isUpdateModalOpen} + onClose={() => setIsUpdateModalOpen(false)} measurement={selectedMeasurement} onUpdateMeasurement={(update) => - setSelectedMeasurement({ ...selectedMeasurement, ...update }) + setSelectedMeasurement((prev) => ({ ...prev, ...update })) } onSubmitUpdate={handleSubmitMeasurementUpdate} onDeleteMeasurement={handleDeleteMeasurement} @@ -385,4 +485,4 @@ export const MonitoringWellsView = () => { ); -}; +} diff --git a/frontend/src/routes/notifications.tsx b/frontend/src/routes/notifications.tsx new file mode 100644 index 00000000..04d63af5 --- /dev/null +++ b/frontend/src/routes/notifications.tsx @@ -0,0 +1,35 @@ +import { createFileRoute } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { z } from "zod"; +import { Notifications } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + isoDateParam, + pageParam, + positiveIntListParam, + routeSearchHydrator, + triStateParam, +} from "@/utils"; + +const searchSchema = z.object({ + q: z.string().catch("").default(""), + is_read: triStateParam("false"), + notification_type_id: positiveIntListParam, + created_from: isoDateParam.catch(undefined).default(undefined), + created_to: isoDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + page: pageParam(0, 0), + pageSize: pageParam(25, 10), +}); + +export const Route = createFileRoute("/notifications")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/reports/chlorides.tsx b/frontend/src/routes/reports/chlorides.tsx new file mode 100644 index 00000000..e91eda7a --- /dev/null +++ b/frontend/src/routes/reports/chlorides.tsx @@ -0,0 +1,39 @@ +import { createFileRoute } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { ChloridesReportView } from "@/views/Reports/Chlorides"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + isoDateParam, + mapBaseLayerSchema, + mapLatSchema, + mapLngSchema, + mapOverlayNamesSchema, + mapZoomSchema, + routeSearchHydrator, +} from "@/utils"; +import { z } from "zod"; + +const searchSchema = z.object({ + from: isoDateParam + .catch(dayjs().startOf("month").format("YYYY-MM-DD")) + .default(dayjs().startOf("month").format("YYYY-MM-DD")), + to: isoDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + mapBase: mapBaseLayerSchema.catch("OpenStreetMap").default("OpenStreetMap"), + mapOverlays: mapOverlayNamesSchema, + mapLat: mapLatSchema, + mapLng: mapLngSchema, + mapZoom: mapZoomSchema, +}); + +export const Route = createFileRoute("/reports/chlorides")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/reports/index.tsx b/frontend/src/routes/reports/index.tsx new file mode 100644 index 00000000..310a98a3 --- /dev/null +++ b/frontend/src/routes/reports/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ReportsView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/reports/")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/reports/maintenance.tsx b/frontend/src/routes/reports/maintenance.tsx new file mode 100644 index 00000000..a9501262 --- /dev/null +++ b/frontend/src/routes/reports/maintenance.tsx @@ -0,0 +1,44 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import dayjs from "dayjs"; +import { MaintenanceReportView } from "@/views/Reports/Maintenance"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + isoDateParam, + optionalNonNegativeInt, + optionalTrimmedString, + positiveIntListParam, + routeSearchHydrator, +} from "@/utils"; + +const pageSizeSchema = z.preprocess((val) => { + const raw = Array.isArray(val) ? val[0] : val; + if (raw == null || raw === "") return undefined; + const n = Number(raw); + const allowed = new Set([5, 10, 25, 50, 100]); + return Number.isInteger(n) && allowed.has(n) ? n : undefined; +}, z.number().int().optional()).catch(5).default(5); + +const searchSchema = z.object({ + from: isoDateParam + .catch(dayjs().startOf("month").format("YYYY-MM-DD")) + .default(dayjs().startOf("month").format("YYYY-MM-DD")), + to: isoDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + trss: optionalTrimmedString.catch("").default(""), + technicians: positiveIntListParam, + page: optionalNonNegativeInt.catch(0).default(0), + pageSize: pageSizeSchema, +}); + +export const Route = createFileRoute("/reports/maintenance")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/reports/monitoringwells.tsx b/frontend/src/routes/reports/monitoringwells.tsx new file mode 100644 index 00000000..cbaa5c51 --- /dev/null +++ b/frontend/src/routes/reports/monitoringwells.tsx @@ -0,0 +1,48 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import dayjs from "dayjs"; + +import { MonitoringWellsReportView } from "@/views/Reports/MonitoringWells"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + booleanParam, + dayjsDateParam, + optionalPositiveInt, + pageParam, + positiveIntListParam, + routeSearchHydrator, +} from "@/utils"; + +const searchSchema = z.object({ + // form fields + from: dayjsDateParam + .catch(dayjs().startOf("month").format("YYYY-MM-DD")) + .default(dayjs().startOf("month").format("YYYY-MM-DD")), + to: dayjsDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + well_ids: positiveIntListParam, + + avgAll: booleanParam(false), + cmp1970: booleanParam(false), + cmpYear: optionalPositiveInt.catch(undefined).default(undefined), + + // manual measurements DataGrid pagination + m_page: pageParam(0, 0, 500), + m_pageSize: pageParam(5, 5, 50), + + // averages DataGrid pagination + a_page: pageParam(0, 0, 500), + a_pageSize: pageParam(5, 5, 50), +}); + +export const Route = createFileRoute("/reports/monitoringwells")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/reports/partsused.tsx b/frontend/src/routes/reports/partsused.tsx new file mode 100644 index 00000000..c4036493 --- /dev/null +++ b/frontend/src/routes/reports/partsused.tsx @@ -0,0 +1,36 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import dayjs from "dayjs"; +import { PartsUsedReportView } from "@/views/Reports/PartsUsed"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + booleanParam, + dayjsDateParam, + pageParam, + positiveIntListParam, + routeSearchHydrator, +} from "@/utils"; +const searchSchema = z.object({ + from: dayjsDateParam + .catch(dayjs().startOf("month").format("YYYY-MM-DD")) + .default(dayjs().startOf("month").format("YYYY-MM-DD")), + to: dayjsDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + part_types: positiveIntListParam, + parts: positiveIntListParam, + in_use: booleanParam(true), + page: pageParam(0, 0, 1000), + pageSize: pageParam(5, 5, 100), +}); + +export const Route = createFileRoute("/reports/partsused")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx new file mode 100644 index 00000000..5a5fef59 --- /dev/null +++ b/frontend/src/routes/settings.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Settings } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/settings")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/workorders.tsx b/frontend/src/routes/workorders.tsx new file mode 100644 index 00000000..924bf085 --- /dev/null +++ b/frontend/src/routes/workorders.tsx @@ -0,0 +1,75 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Card, CardContent } from "@mui/material"; +import { AssignmentTurnedInOutlined } from "@mui/icons-material"; +import { z } from "zod"; + +import { WorkOrderStatus } from "@/enums"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +import { WorkOrdersTable } from "@/views/WorkOrders"; +import { + optionalPositiveInt, + optionalTrimmedString, + pageParam, + positiveIntListParam, + routeSearchHydrator, +} from "@/utils"; + +const statusEnum = z.nativeEnum(WorkOrderStatus); +export type WorkOrderStatusParam = z.infer; + +/** + * Accepts: + * - ?status=Open&status=Review + * - ?status=Open,Review + * - or undefined + */ +const statusListSchema = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + + const raw = Array.isArray(val) ? val : [val]; + + const items = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter(Boolean); + + return items.length ? items : undefined; +}, z.array(statusEnum).optional()); +const searchSchema = z.object({ + status: statusListSchema + .catch([WorkOrderStatus.Open, WorkOrderStatus.Review]) + .default([WorkOrderStatus.Open, WorkOrderStatus.Review]), + assigned_user_id: optionalPositiveInt.catch(undefined).default(undefined), + q: optionalTrimmedString.catch("").default(""), + work_order_id: positiveIntListParam, + page: pageParam(0, 0), + pageSize: pageParam(25, 10), +}); + +export const Route = createFileRoute("/workorders")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); + +function WorkOrdersView() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 7a96bc04..fb8597cc 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1,13 +1,26 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient, UseQueryOptions } from "react-query"; +import { + InfiniteData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "react-query"; import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { ActivityTypeLU, + CreateNotificationPayload, + HomeSummary, MeterListDTO, MeterListQueryParams, MeterTypeLU, NewWellMeasurement, NoteTypeLU, + Notification, + NotificationCreateResult, + NotificationQueryParams, + NotificationType, ObservedPropertyTypeLU, Page, ST2Measurement, @@ -44,10 +57,90 @@ import { MeterRegister, WaterSource, WellStatus, -} from "../interfaces.js"; -import { WorkOrderStatus } from "../enums"; -import { useNavigate } from "react-router-dom"; -import { API_URL } from "../config"; +} from "@/interfaces"; +import { IncreaseQuantityPayload } from "@/interfaces"; +import { WorkOrderStatus } from "@/enums"; +import { API_URL } from "@/config"; +import { useNavigate } from "@tanstack/react-router"; +import { + PartHistoryResponse, + UpdatePartHistoryPayload, +} from "@/interfaces/PartHistoryResponse"; + +// Cashe for up to 48 hours +const MAP_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 2; +const MAP_CACHE_PREFIX = "wmdb:map-cache:"; +const MAP_QUERY_ROUTES = ["meters_locations", "well_locations"] as const; + +type StoredMapCache = { + data: T; + updatedAt: number; +}; + +function getMapCacheStorageKey(queryKey: readonly unknown[]) { + return `${MAP_CACHE_PREFIX}${JSON.stringify(queryKey)}`; +} + +function readMapCache(queryKey: readonly unknown[]) { + if (typeof window === "undefined") return undefined; + + const storageKey = getMapCacheStorageKey(queryKey); + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) return undefined; + + try { + const parsed = JSON.parse(rawValue) as StoredMapCache; + if ( + !parsed || + typeof parsed.updatedAt !== "number" || + Date.now() - parsed.updatedAt > MAP_CACHE_TTL_MS + ) { + window.localStorage.removeItem(storageKey); + return undefined; + } + + return parsed; + } catch { + window.localStorage.removeItem(storageKey); + return undefined; + } +} + +function writeMapCache(queryKey: readonly unknown[], data: T) { + if (typeof window === "undefined") return; + + const storageKey = getMapCacheStorageKey(queryKey); + const value: StoredMapCache = { + data, + updatedAt: Date.now(), + }; + + window.localStorage.setItem(storageKey, JSON.stringify(value)); +} + +export function clearSavedQueryLocalStorage() { + if (typeof window === "undefined") return; + + const keysToRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(MAP_CACHE_PREFIX)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => window.localStorage.removeItem(key)); +} + +function invalidateMapDataCaches( + queryClient: ReturnType, +) { + clearSavedQueryLocalStorage(); + MAP_QUERY_ROUTES.forEach((route) => { + queryClient.removeQueries(route); + queryClient.invalidateQueries(route); + }); +} // Date display util export function toGMT6String(date: Date) { @@ -106,15 +199,18 @@ async function GETFetch( navigate: Function, ) { const headers = { Authorization: authHeader }; - const response = await fetch(`${API_URL}/${route}` + formattedQueryParams(params), { - headers: headers, - }); + const response = await fetch( + `${API_URL}/${route}` + formattedQueryParams(params), + { + headers: headers, + }, + ); if (!response.ok) { // If backend indicates that user's token is expired, log them out and notify if (response.status == 440 && localStorage.getItem("loggedIn")) { localStorage.removeItem("loggedIn"); - navigate("/"); + navigate({ to: "/" }); signOut(); enqueueSnackbar("Your session has expired, please login again.", { variant: "error", @@ -235,18 +331,24 @@ export function useGetMeterLocations(searchstring: string | undefined) { const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); + const queryKey = [route, searchstring] as const; + const cachedData = readMapCache(queryKey); 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 + queryKey, + queryFn: () => + GETFetch( + route, + { search_string: searchstring }, + authHeader(), + signOut, + navigate, + ), + initialData: cachedData?.data, + initialDataUpdatedAt: cachedData?.updatedAt, + onSuccess: (data) => writeMapCache(queryKey, data), + staleTime: MAP_CACHE_TTL_MS, + cacheTime: MAP_CACHE_TTL_MS, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, @@ -264,6 +366,65 @@ export function useGetMeterTypeList() { ); } +export function useGetHomeSummary() { + const route = "maintenance/home_summary"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery([route], () => + GETFetch(route, null, authHeader(), signOut, navigate), + ); +} + +export function useGetNotifications( + params: NotificationQueryParams | undefined, + options?: UseQueryOptions, Error>, +) { + const route = "notifications"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery, Error>( + [route, params], + () => GETFetch(route, params, authHeader(), signOut, navigate), + { + keepPreviousData: true, + ...options, + }, + ); +} + +export function useGetNotificationTypes() { + const route = "notification_types"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery([route], () => + GETFetch(route, null, authHeader(), signOut, navigate), + ); +} + +export function useGetUnreadNotificationCount( + options?: UseQueryOptions<{ unread_count: number }, Error>, +) { + const route = "notifications/unread_count"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery<{ unread_count: number }, Error>( + [route], + () => GETFetch(route, null, authHeader(), signOut, navigate), + { + refetchInterval: 60_000, + ...options, + }, + ); +} + export function useGetMeterRegisterList() { const route = "meter_registers"; const authHeader = useAuthHeader(); @@ -321,25 +482,29 @@ export function useGetSecurityScopes() { ); } -export function useGetRoles() { +export function useGetRoles(options?: UseQueryOptions) { const route = "roles"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery([route], () => - GETFetch(route, null, authHeader(), signOut, navigate), + return useQuery( + [route], + () => GETFetch(route, null, authHeader(), signOut, navigate), + options, ); } -export function useGetUserAdminList() { +export function useGetUserAdminList(options?: UseQueryOptions) { const route = "usersadmin"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery([route], () => - GETFetch(route, null, authHeader(), signOut, navigate), + return useQuery( + [route], + () => GETFetch(route, null, authHeader(), signOut, navigate), + options, ); } @@ -354,6 +519,19 @@ export function useGetUserList() { ); } +export function useGetUser(id: number, options = {}) { + const route = "users"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery( + [route, id], + () => GETFetch(`${route}/${id}`, null, authHeader(), signOut, navigate), + options, + ); +} + export function useGetActivityTypeList() { const route = "activity_types"; const authHeader = useAuthHeader(); @@ -409,6 +587,26 @@ export function useGetPropertyTypes() { ); } +export function useGetWellById(well_id?: number) { + const route = "wells"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery( + [route, "detail", well_id], + () => + GETFetch( + `${route}/${well_id}`, + undefined, + authHeader(), + signOut, + navigate, + ), + { enabled: !!well_id }, + ); +} + export function useGetWells(params: WellListQueryParams | undefined) { const route = "wells"; const authHeader = useAuthHeader(); @@ -422,22 +620,32 @@ export function useGetWells(params: WellListQueryParams | undefined) { ); } -export function useGetWellLocations(searchstring: string | undefined, has_chloride_group: boolean | null = null) { +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; + const queryKey = [route, searchstring, has_chloride_group] as const; + const cachedData = readMapCache>(queryKey); return useInfiniteQuery({ - queryKey: [route, searchstring, has_chloride_group], + queryKey, queryFn: async ({ pageParam = 0 }) => { return GETFetch( route, - { search_string: searchstring, offset: pageParam, limit: PAGE_SIZE, has_chloride_group }, + { + search_string: searchstring, + offset: pageParam, + limit: PAGE_SIZE, + has_chloride_group, + }, authHeader(), signOut, - navigate + navigate, ); }, getNextPageParam: (lastPage, allPages) => { @@ -445,15 +653,17 @@ export function useGetWellLocations(searchstring: string | undefined, has_chlori 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, + initialData: cachedData?.data, + initialDataUpdatedAt: cachedData?.updatedAt, + onSuccess: (data) => writeMapCache(queryKey, data), + staleTime: MAP_CACHE_TTL_MS, + cacheTime: MAP_CACHE_TTL_MS, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, }); } - export function useGetWell(params: WellDetailsQueryParams | undefined) { const route = "well"; const authHeader = useAuthHeader(); @@ -558,24 +768,34 @@ export function useGetST2WaterLevels(datastreamID: number | undefined) { } export function useGetWorkOrders( - status_filter: WorkOrderStatus[], - options?: UseQueryOptions + params: { + filter_by_status: WorkOrderStatus[]; + start_date?: string; // ISO date string (YYYY-MM-DD) + work_order_id?: number[]; + assigned_user_id?: number; + q?: string; + }, + options?: UseQueryOptions, ) { const route = "work_orders"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); + // normalize params so queryKey is stable (order of arrays matters) + const normalized = { + ...params, + filter_by_status: [...(params.filter_by_status ?? [])].sort(), + work_order_id: params.work_order_id + ? [...params.work_order_id].sort((a, b) => a - b) + : undefined, + q: params.q?.trim() || undefined, + }; + return useQuery({ - queryKey: [route, { status_filter: status_filter.sort() }], - queryFn: () => GETFetch( - route, - { filter_by_status: status_filter }, - authHeader(), - signOut, - navigate, - ), - ...options + queryKey: [route, normalized], + queryFn: () => GETFetch(route, normalized, authHeader(), signOut, navigate), + ...options, }); } @@ -667,6 +887,7 @@ export function useCreateWell(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "wells"; const authHeader = useAuthHeader(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (new_well: SubmitWellCreate) => { @@ -691,6 +912,7 @@ export function useCreateWell(onSuccess: Function) { } else { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); return responseJson; } }, @@ -735,6 +957,71 @@ export function useCreateRole(onSuccess: Function) { }); } +export function useCreateNotifications(onSuccess: Function) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const route = "notifications"; + const authHeader = useAuthHeader(); + + return useMutation({ + mutationFn: async (payload: CreateNotificationPayload) => { + const response = await POSTFetch(route, payload, authHeader()); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + const responseJson: NotificationCreateResult = await response.json(); + onSuccess(responseJson); + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + return responseJson; + }, + onSuccess: (result) => { + enqueueSnackbar( + `Created ${result.created_count} notification${result.created_count === 1 ? "" : "s"}.`, + { + variant: "success", + }, + ); + }, + retry: 0, + }); +} + +export function useUpdateNotificationReadStatus(onSuccess?: Function) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const route = "notifications"; + const authHeader = useAuthHeader(); + + return useMutation({ + mutationFn: async (payload: { id: number; is_read: boolean }) => { + const response = await PATCHFetch(route, payload, authHeader()); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return response.json(); + }, + onSuccess: (result) => { + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + onSuccess?.(result); + }, + retry: 0, + }); +} + export function useUpdateWell(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "wells"; @@ -764,6 +1051,7 @@ export function useUpdateWell(onSuccess: Function) { } else { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); // Since query data will be based on params, iterate through all possible queries of this route const wellsQueries = queryClient.getQueryCache().findAll("wells"); @@ -930,6 +1218,7 @@ export function useCreateMeter(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "meters"; const authHeader = useAuthHeader(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (meter: Meter) => { @@ -955,6 +1244,7 @@ export function useCreateMeter(onSuccess: Function) { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); return responseJson; } }, @@ -1087,6 +1377,7 @@ export function useUpdateMeter(onSuccess: Function) { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); // Since query data will be based on params, iterate through all possible queries of this route const meterQueries = queryClient.getQueryCache().findAll("meters"); @@ -1255,9 +1546,14 @@ export function useCreatePart(onSuccess: Function) { return useMutation({ mutationFn: async (part: Part) => { try { - //Due to the way the form gets generated for a new part, I need to populate part_type_id manually here + if (!part.part_type?.id) { + throw new Error("part_type_id is required but missing"); + } + + // Due to the way the form gets generated for a new part, + // I need to populate part_type_id manually here part.part_type_id = part.part_type?.id; - console.log(part); + const response = await POSTFetch(route, part, authHeader()); if (!response.ok) { @@ -1353,7 +1649,7 @@ export function useCreateWaterLevel() { const authHeader = useAuthHeader(); return useMutation({ - mutationFn: async (newWaterLevel: NewWellMeasurement) => { + mutationFn: async (newWaterLevel: Partial) => { const response = await POSTFetch(route, newWaterLevel, authHeader()); if (!response.ok) { @@ -1392,7 +1688,7 @@ export function useUpdateWaterLevel(onSuccess: Function) { const authHeader = useAuthHeader(); return useMutation({ - mutationFn: async (updatedWaterLevel: PatchWellMeasurement) => { + mutationFn: async (updatedWaterLevel: Partial) => { const response = await PATCHFetch(route, updatedWaterLevel, authHeader()); if (!response.ok) { @@ -1590,3 +1886,138 @@ export function useCreateWorkOrder() { retry: 0, }); } + +export function useAddParts(onSuccess?: () => void) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const authHeader = useAuthHeader(); + + const route = "parts/add"; + + return useMutation({ + mutationFn: async (payload: IncreaseQuantityPayload) => { + const response = await POSTFetch(route, payload, authHeader()); + + if (!response.ok) { + if (response.status === 404) { + enqueueSnackbar("Part not found.", { variant: "error" }); + throw new Error("Part not found (404)"); + } + + if (response.status === 422) { + enqueueSnackbar("Missing or invalid fields.", { variant: "error" }); + throw new Error("Validation error (422)"); + } + + // Optional: read backend detail if present + let detail = ""; + try { + const j = await response.json(); + detail = j?.detail ? ` (${j.detail})` : ""; + } catch {} + + enqueueSnackbar( + `Unknown error occurred! (${response.status})${detail}`, + { + variant: "error", + }, + ); + throw new Error(`Unknown Error: ${response.status}${detail}`); + } + + const updatedPart: Part = await response.json(); + + // update any cached parts lists you have + queryClient.setQueryData(["parts"], (old) => { + const safeOld = old ?? []; + return safeOld.map((p) => (p.id === updatedPart.id ? updatedPart : p)); + }); + + onSuccess?.(); + return updatedPart; + }, + retry: 0, + }); +} + +export function useGetPartHistory(partId?: string) { + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery( + ["parts-history", partId], + () => + GETFetch( + `parts/${partId}/history`, + null, + authHeader(), + signOut, + navigate, + ), + { enabled: !!partId, keepPreviousData: true }, + ); +} + +export function useUpdatePartHistory( + partId?: string, + onSuccess?: (response: PartHistoryResponse) => void, +) { + const { enqueueSnackbar } = useSnackbar(); + const authHeader = useAuthHeader(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: UpdatePartHistoryPayload) => { + if (!partId) { + throw new Error("Missing part id"); + } + + const response = await PATCHFetch( + `parts/${partId}/history`, + payload, + authHeader(), + ); + + if (!response.ok) { + let detail = ""; + try { + const json = await response.json(); + detail = json?.detail ? ` (${json.detail})` : ""; + } catch {} + + if (response.status === 404) { + enqueueSnackbar(`Part history row not found${detail}`, { + variant: "error", + }); + throw new Error(`Part history row not found${detail}`); + } + + if (response.status === 422) { + enqueueSnackbar(`Invalid history update${detail}`, { + variant: "error", + }); + throw new Error(`Invalid history update${detail}`); + } + + enqueueSnackbar( + `Unknown error occurred! (${response.status})${detail}`, + { + variant: "error", + }, + ); + throw new Error(`Unknown Error: ${response.status}${detail}`); + } + + const responseJson: PartHistoryResponse = await response.json(); + + queryClient.setQueryData(["parts-history", partId], responseJson); + queryClient.invalidateQueries({ queryKey: ["parts"] }); + queryClient.invalidateQueries({ queryKey: ["part"] }); + + onSuccess?.(responseJson); + return responseJson; + }, + retry: 0, + }); +} diff --git a/frontend/src/service/index.ts b/frontend/src/service/index.ts new file mode 100644 index 00000000..67ffb42e --- /dev/null +++ b/frontend/src/service/index.ts @@ -0,0 +1 @@ +export * from "./ApiServiceNew"; diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index a9198eb7..6dc1d0d9 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,38 +1,206 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useAuthUser } from "react-auth-kit"; import { + Badge, Box, + ButtonBase, Collapse, - Drawer, - Grid, - IconButton, - List, - ListSubheader, - Toolbar, Typography, + useMediaQuery, + useTheme, } from "@mui/material"; -import { useNavigate } from "react-router-dom"; -import { ChevronLeft } from "@mui/icons-material"; -import { NavLink, ReportsNavItem, RoleChip } from "./components"; -import { useGetWorkOrders } from "./service/ApiServiceNew"; -import { WorkOrderStatus } from "./enums"; -import { SecurityScope, WorkOrder } from "./interfaces"; -import { navConfig } from "./constants"; +import { SvgIconProps } from "@mui/material/SvgIcon"; +import { useNavigate } from "@tanstack/react-router"; +import { + AssessmentOutlined, + ExpandLess, + ExpandMore, + TableView, +} from "@mui/icons-material"; +import { useIsActiveRoute } from "@/hooks"; +import { useGetWorkOrders } from "@/service"; +import { WorkOrderStatus } from "@/enums"; +import { SecurityScope, WorkOrder } from "@/interfaces"; +import { navConfig } from "@/constants"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarHeaderCloseButton, + SidebarMenu, + SidebarMenuSub, + SidebarTooltip, + TOPBAR_HEIGHT, +} from "@/components/ui/sidebar"; + +const reportsMenuOpenStorageKey = "wmdb.sidebar.reports.open"; + +const readStoredReportsMenuOpen = () => { + if (typeof window === "undefined") return true; + + const raw = window.localStorage.getItem(reportsMenuOpenStorageKey); + if (raw === null) return true; + return raw === "true"; +}; + +type NavButtonProps = { + route?: string; + label: string; + icon?: React.ComponentType; + badgeContent?: number; + subItem?: boolean; + collapsed?: boolean; + disabled?: boolean; + onClick: () => void; + trailing?: React.ReactNode; +}; + +function SidebarNavButton({ + route, + label, + icon: Icon, + badgeContent, + subItem = false, + collapsed = false, + disabled = false, + onClick, + trailing, +}: NavButtonProps) { + const active = route ? useIsActiveRoute(route) : false; + const iconNode = Icon ? ( + + ) : ( + + ); + const content = ( + + + + {iconNode} + + + {!collapsed ? ( + <> + + + {label} + + + {trailing} + + ) : null} + + ); + + return collapsed ? ( + {content} + ) : ( + content + ); +} + +function ReportsSidebarButton({ + open, + setOpen, +}: { + open: boolean; + setOpen: React.Dispatch>; +}) { + return ( + setOpen((prev) => !prev)} + trailing={ + + {open ? ( + + ) : ( + + )} + + } + icon={AssessmentOutlined} + /> + ); +} export default function Sidenav({ open, drawerWidth, onClose, + onOpen, + onWidthChange, }: { open: boolean; drawerWidth: number; onClose: () => void; + onOpen: () => void; + onWidthChange: (width: number) => void; }) { - const [openReportsMenu, setOpenReportsMenu] = useState(true); + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); + const [openReportsMenu, setOpenReportsMenu] = useState( + readStoredReportsMenuOpen, + ); const navigate = useNavigate(); const authUser = useAuthUser(); - // 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, @@ -43,11 +211,17 @@ export default function Sidenav({ const hasAdminScope = scopes.has("admin"); const userId = authUser()?.id; const [workOrderCount, setWorkOrderCount] = useState(0); - const openWorkOrdersQuery = useGetWorkOrders([WorkOrderStatus.Open], { - refetchInterval: 45_000, - refetchIntervalInBackground: true, - enabled: hasReadScope && !!authUser(), - }); + + const openWorkOrdersQuery = useGetWorkOrders( + { + filter_by_status: [WorkOrderStatus.Open], + }, + { + refetchInterval: 45_000, + refetchIntervalInBackground: true, + enabled: hasReadScope && !!authUser(), + }, + ); useEffect(() => { if (openWorkOrdersQuery.data && userId) { @@ -59,145 +233,202 @@ export default function Sidenav({ } }, [openWorkOrdersQuery.data, userId]); + useEffect(() => { + if (!isDesktop) { + return; + } + + setOpenReportsMenu(readStoredReportsMenuOpen()); + }, [isDesktop]); + + useEffect(() => { + if (!isDesktop || typeof window === "undefined") return; + window.localStorage.setItem( + reportsMenuOpenStorageKey, + String(openReportsMenu), + ); + }, [isDesktop, openReportsMenu]); + + const visibleCollapsedItems = useMemo( + () => [ + ...navConfig.filter((item) => !item.role), + ...navConfig.filter((item) => item.role === "Technician" && !item.parent), + ...navConfig.filter((item) => item.parent === "reports"), + ...navConfig.filter((item) => item.role === "Admin"), + ], + [], + ); + + const handleNavigate = (route: string) => { + navigate({ to: route, search: {} }); + if (!isDesktop) { + onClose(); + } + }; + + const isCollapsedDesktop = isDesktop && !open; + return ( - - {/* Header */} - - navigate("/")} - > - Meter Manager - - - - - - - - - {/* Nav Items */} - - Pages}> - {navConfig - .filter((item) => !item.role) - .map((item) => ( - - ))} - {hasReadScope && ( - <> - - Pages - - {navConfig - .filter((item) => item.role === "Technician" && !item.parent) - .map((item) => ( - + + + + + {visibleCollapsedItems + .filter((item) => { + if (!item.role) { + return true; + } + if (item.role === "Technician") { + return hasReadScope; + } + if (item.role === "Admin") { + return hasAdminScope; + } + return false; + }) + .map((item, index) => ( + + handleNavigate(item.path)} /> - ))} - - - + + ))} + + + ) : ( + <> + + handleNavigate("/")} + sx={{ + display: "flex", + alignItems: "center", + gap: 1.25, + borderRadius: 2, + pr: 1, + textAlign: "left", + }} + > + + + + + + Pages + + {navConfig + .filter((item) => !item.role) + .map((item) => ( + handleNavigate(item.path)} + /> + ))} + + + {hasReadScope ? ( + {navConfig - .filter((item) => item.parent === "reports") + .filter( + (item) => item.role === "Technician" && !item.parent, + ) .map((item) => ( - handleNavigate(item.path)} /> ))} - - - - )} - {hasAdminScope && ( - <> - - Pages - - {navConfig - .filter((item) => item.role === "Admin") - .map((item) => ( - - ))} - - )} - - - + + + {navConfig + .filter((item) => item.parent === "reports") + .map((item) => ( + handleNavigate(item.path)} + /> + ))} + + + + ) : null} + + {hasAdminScope ? ( + + {navConfig + .filter((item) => item.role === "Admin") + .map((item) => ( + handleNavigate(item.path)} + /> + ))} + + ) : null} + + + + )} + ); } diff --git a/frontend/src/utils/AssertDefined.ts b/frontend/src/utils/AssertDefined.ts new file mode 100644 index 00000000..ad9efe0f --- /dev/null +++ b/frontend/src/utils/AssertDefined.ts @@ -0,0 +1,8 @@ +export function assertDefined( + value: T, + message = "Value is required", +): asserts value is NonNullable { + if (value === undefined || value === null) { + throw new Error(message); + } +} diff --git a/frontend/src/utils/MapUrlState.ts b/frontend/src/utils/MapUrlState.ts new file mode 100644 index 00000000..f87ac48c --- /dev/null +++ b/frontend/src/utils/MapUrlState.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +export const DEFAULT_MAP_CENTER: [number, number] = [33, -104]; +export const DEFAULT_MAP_ZOOM = 8; + +export const MAP_BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; + +const optionalSearchString = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const s = String(raw).trim(); + return s.length ? s : undefined; +}, z.string().optional()); + +const optionalSearchNumber = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +}, z.number().optional()); + +export const mapBaseLayerSchema = optionalSearchString; + +export const mapOverlayNamesSchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val : [val]; + const items = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter(Boolean); + + return items.length ? items : undefined; + }, z.array(z.string()).optional()) + .catch(undefined); + +export const mapLatSchema = optionalSearchNumber + .pipe(z.number().min(-90).max(90).optional()) + .catch(undefined); + +export const mapLngSchema = optionalSearchNumber + .pipe(z.number().min(-180).max(180).optional()) + .catch(undefined); + +export const mapZoomSchema = optionalSearchNumber + .pipe(z.number().int().min(0).max(22).optional()) + .catch(undefined); + +type MapSearchState = { + mapBase?: string; + mapOverlays?: string[]; + mapLat?: number; + mapLng?: number; + mapZoom?: number; +}; + +const roundCoordinate = (value: number) => Number(value.toFixed(5)); + +export const normalizeMapBaseLayer = ( + value: string | undefined, + allowed: readonly string[], + fallback: string, +) => (value && allowed.includes(value) ? value : fallback); + +export const normalizeMapOverlayNames = ( + value: string[] | undefined, + allowed: readonly string[], + fallback: string[], +) => { + const source = value?.length ? value : fallback; + return [...new Set(source.filter((name) => allowed.includes(name)))].sort(); +}; + +export const parseMapView = ( + search: MapSearchState, + fallback = { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }, +) => ({ + center: [ + search.mapLat ?? fallback.center[0], + search.mapLng ?? fallback.center[1], + ] as [number, number], + zoom: search.mapZoom ?? fallback.zoom, +}); + +export const serializeMapView = ( + center: { lat: number; lng: number }, + zoom: number, + fallback = { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }, +) => { + const lat = roundCoordinate(center.lat); + const lng = roundCoordinate(center.lng); + + return { + mapLat: lat === fallback.center[0] ? undefined : lat, + mapLng: lng === fallback.center[1] ? undefined : lng, + mapZoom: zoom === fallback.zoom ? undefined : zoom, + }; +}; + +export const getMapLayersControlKey = ( + baseLayerName: string, + overlayNames: string[], +) => `${baseLayerName}::${overlayNames.slice().sort().join("|")}`; diff --git a/frontend/src/utils/RouteSearch.ts b/frontend/src/utils/RouteSearch.ts new file mode 100644 index 00000000..f73f4e28 --- /dev/null +++ b/frontend/src/utils/RouteSearch.ts @@ -0,0 +1,104 @@ +import { redirect } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { z } from "zod"; + +export const firstValue = (value: unknown) => + Array.isArray(value) ? value[0] : value; + +export const optionalPositiveInt = z.preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null || raw === "") return undefined; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : undefined; +}, z.number().int().positive().optional()); + +export const optionalNonNegativeInt = z.preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null || raw === "") return undefined; + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : undefined; +}, z.number().int().nonnegative().optional()); + +export const optionalTrimmedString = z.preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null) return undefined; + const s = String(raw).trim(); + return s.length ? s : undefined; +}, z.string().optional()); + +export const booleanParam = (defaultValue: boolean) => + z + .preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null || raw === "") return undefined; + if (raw === true || raw === "true" || raw === "1" || raw === 1) + return true; + if (raw === false || raw === "false" || raw === "0" || raw === 0) + return false; + return undefined; + }, z.boolean().optional()) + .catch(defaultValue) + .default(defaultValue); + +export const triStateParam = (defaultValue: "all" | "true" | "false") => + z.enum(["all", "true", "false"]).catch(defaultValue).default(defaultValue); + +export const isoDateParam = z.preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null || raw === "") return undefined; + const s = String(raw).trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : undefined; +}, z.string().optional()); + +export const dayjsDateParam = z.preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null || raw === "") return undefined; + const s = String(raw).trim(); + return dayjs(s, "YYYY-MM-DD", true).isValid() ? s : undefined; +}, z.string().optional()); + +export const positiveIntListParam = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return []; + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter(Boolean) + .map(Number) + .filter((n) => Number.isInteger(n) && n > 0); + return Array.from(new Set(nums)); + }, z.array(z.number().int().positive())) + .catch([]) + .default([]); + +export const pageParam = (defaultValue = 0, min = 0, max = 200) => + z.coerce + .number() + .int() + .min(min) + .max(max) + .catch(defaultValue) + .default(defaultValue); + +export function routeSearchHydrator>( + to: string, + search: TSearch, + searchStr: string, +) { + const current = new URLSearchParams( + searchStr.startsWith("?") ? searchStr : "", + ); + const shouldHydrate = Object.entries(search).some(([key, value]) => { + if (value === undefined || value === null) return false; + return !current.has(key); + }); + + if (shouldHydrate) { + throw redirect({ + to, + replace: true, + search: search as any, + }); + } +} diff --git a/frontend/src/utils/UserRoleGrouping.ts b/frontend/src/utils/UserRoleGrouping.ts new file mode 100644 index 00000000..495ee235 --- /dev/null +++ b/frontend/src/utils/UserRoleGrouping.ts @@ -0,0 +1,44 @@ +import { ROLE_IDS } from "@/config"; +import { User } from "@/interfaces"; + +export type RoleLabel = "Admin" | "Technician" | "OSE" | "Unknown"; + +export const getRoleLabel = (user: User): RoleLabel => { + const roleId = user.user_role_id ?? user.user_role?.id; + switch (roleId) { + case ROLE_IDS.ADMIN: + return "Admin"; + case ROLE_IDS.TECHNICIAN: + return "Technician"; + case ROLE_IDS.OSE: + return "OSE"; + default: + switch (user.user_role?.name?.toLowerCase()) { + case "admin": + return "Admin"; + case "technician": + case "tech": + return "Technician"; + case "ose": + return "OSE"; + default: + return "Unknown"; + } + } +}; + +export const roleOrder: Record = { + Admin: 2, + Technician: 1, + OSE: 3, + Unknown: 99, +}; + +export const sortUsersByRoleThenName = (users: User[]): User[] => { + return [...users].sort((a, b) => { + const roleCompare = roleOrder[getRoleLabel(a)] - roleOrder[getRoleLabel(b)]; + if (roleCompare !== 0) return roleCompare; + + return (a.full_name ?? "").localeCompare(b.full_name ?? ""); + }); +}; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 23db1355..bd947722 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,8 +1,12 @@ +export * from "./AssertDefined"; export * from "./DateUtils"; +export * from "./DataStreamUtils"; export * from "./EmptyToNull"; export * from "./HttpUtils"; +export * from "./MapUrlState"; export * from "./GetMeterMarkerColor"; export * from "./GetRoleColor"; export * from "./MemoryUtils"; export * from "./MonitoredWellsUtils"; export * from "./NumberDataFormatter"; +export * from "./RouteSearch"; diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index e7b41bce..5e3cb2bd 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,14 +1,13 @@ 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"; +import { Engineering } from "@mui/icons-material"; +import { BackgroundBox, CustomCardHeader } from "@/components"; export const ActivitiesView = () => { return ( - + diff --git a/frontend/src/views/Activities/ActivityPhotoView.tsx b/frontend/src/views/Activities/ActivityPhotoView.tsx index deba9508..9470c2fa 100644 --- a/frontend/src/views/Activities/ActivityPhotoView.tsx +++ b/frontend/src/views/Activities/ActivityPhotoView.tsx @@ -1,12 +1,14 @@ import { useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; -import { API_URL } from "../../config"; +import { useParams } from "@tanstack/react-router"; import { Card, CardContent, Skeleton, Box, Alert } from "@mui/material"; import { Image } from "@mui/icons-material"; -import { BackgroundBox, CustomCardHeader } from "../../components"; +import { API_URL } from "@/config"; +import { BackgroundBox, CustomCardHeader } from "@/components"; export const ActivityPhotoView = () => { - const { activity_id, photo_file_name } = useParams(); + const { activity_id, photo_file_name } = useParams({ + from: "/activities/$activity_id/photos/$photo_file_name", + }); const [loaded, setLoaded] = useState(false); const [error, setError] = useState(); diff --git a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts index 72a6d894..39dd1898 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts +++ b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts @@ -1,12 +1,12 @@ import * as Yup from "yup"; +import Dayjs from "dayjs"; +import dayjs from "dayjs"; import { ActivityForm, ActivityFormControl, MeterListDTO, ObservationForm, -} from "../../../interfaces.d"; -import Dayjs from "dayjs"; -import dayjs from "dayjs"; +} from "@/interfaces"; // Form validation, these are applied to the current form when submitting export const ActivityResolverSchema: Yup.ObjectSchema = Yup.object() diff --git a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx index d8a7dfff..42778c5f 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx @@ -1,8 +1,7 @@ import { Box, Grid, Typography } from "@mui/material"; import { useFieldArray } from "react-hook-form"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { StyledToggleButton } from "../../../components"; -import { useGetServiceTypes } from "../../../service/ApiServiceNew"; +import { ControlledTextbox, StyledToggleButton } from "@/components"; +import { useGetServiceTypes } from "@/service"; export default function MaintenanceRepairSelection({ control, diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx index ae974f6e..2117c7be 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx @@ -1,32 +1,31 @@ -import { useEffect } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { Alert, Box, Button, Stack, Typography } from "@mui/material"; import { useSnackbar } from "notistack"; import { useForm, SubmitHandler } from "react-hook-form"; +import { useMutation } from "react-query"; +import { useAuthHeader } from "react-auth-kit"; import { yupResolver } from "@hookform/resolvers/yup"; +import { ActivityFormControl, MeterListDTO } from "@/interfaces"; +import { ActivityType } from "@/enums"; +import { useGetMeter, useGetWell } from "@/service"; +import { API_URL } from "@/config"; import { MeterActivitySelection } from "./MeterActivitySelection"; import ObservationSelection from "./ObservationsSelection"; import NotesSelection from "./NotesSelection"; import MeterInstallation from "./MeterInstallation"; import MaintenanceRepairSelection from "./MaintenanceRepairSelection"; import PartsSelection from "./PartsSelection"; -import { ActivityFormControl, MeterListDTO } from "../../../interfaces.d"; -import { ActivityType } from "../../../enums"; -import { useGetMeter, useGetWell } from "../../../service/ApiServiceNew"; import { ActivityResolverSchema, getDefaultForm, toSubmissionForm, } from "./ActivityFormConfig"; -import { useMutation } from "react-query"; -import { useAuthHeader } from "react-auth-kit"; -import { API_URL } from "../../../config"; export default function MeterActivityEntry() { const navigate = useNavigate(); const authHeader = useAuthHeader(); - const [searchParams] = useSearchParams(); + const search = useSearch({ from: "/activities" }); const { enqueueSnackbar } = useSnackbar(); const [meterID, setMeterID] = useState(); const [wellID, setWellID] = useState(); @@ -40,8 +39,15 @@ export default function MeterActivityEntry() { const onSuccessfulSubmit = (activity_id: number, meter_id: number) => { enqueueSnackbar("Successfully Submitted Activity!", { variant: "success" }); navigate({ - pathname: "/manage/meters", - search: `?meter_id=${meter_id}&activity_id=${activity_id}`, + to: "/manage/meters", + search: { + meter_id, + activity_id, + add: false, + tab: undefined, + q: undefined, + filters: undefined, + }, }); }; @@ -83,8 +89,8 @@ export default function MeterActivityEntry() { }); }, onSuccess: (responseJson) => { - const activity_id = responseJson.id; - const meter_id = responseJson.meter_id; + const activity_id: number = responseJson.id; + const meter_id: number = responseJson.meter_id; enqueueSnackbar("Successfully Submitted Activity!", { variant: "success", }); @@ -93,13 +99,13 @@ export default function MeterActivityEntry() { }); let initialMeter: Partial | null = null; - const qpMeterID = searchParams.get("meter_id"); - const qpSerialNumber = searchParams.get("serial_number"); - const qpWorkOrderID = searchParams.get("work_order_id"); + const qpMeterID = search.meter_id; + const qpSerialNumber = search.serial_number; + const qpWorkOrderID = search.work_order_id; if (qpMeterID && qpSerialNumber) { initialMeter = { - id: qpMeterID as unknown as number, + id: qpMeterID, serial_number: qpSerialNumber, }; } @@ -112,10 +118,7 @@ export default function MeterActivityEntry() { formState: { errors }, } = useForm({ resolver: yupResolver(ActivityResolverSchema), - defaultValues: getDefaultForm( - initialMeter, - qpWorkOrderID ? parseInt(qpWorkOrderID) : null, - ), + defaultValues: getDefaultForm(initialMeter, qpWorkOrderID ?? null), }); const onSubmit: SubmitHandler = (data) => diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx index a34539a6..f14690a1 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx @@ -1,11 +1,13 @@ import { Grid } from "@mui/material"; -import ControlledMeterSelection from "../../../components/RHControlled/ControlledMeterSelection"; -import ControlledActivitySelect from "../../../components/RHControlled/ControlledActivitySelect"; -import ControlledUserSelect from "../../../components/RHControlled/ControlledUserSelect"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; -import ControlledCheckbox from "../../../components/RHControlled/ControlledCheckbox"; -import { ControlledWorkOrderSelect } from "../../../components/RHControlled/ControlledWorkOrderSelect"; +import { + ControlledActivitySelect, + ControlledCheckbox, + ControlledDatepicker, + ControlledMeterSelection, + ControlledTimepicker, + ControlledUserSelect, + ControlledWorkOrderSelect, +} from "@/components"; export function MeterActivitySelection({ control, errors, setValue }: any) { return ( @@ -31,7 +33,6 @@ export function MeterActivitySelection({ control, errors, setValue }: any) { @@ -62,12 +61,12 @@ export default function MeterInstallation({ control, errors, watch }: any) { {watch("current_installation.well")?.location?.latitude == - null + null ? "--" : formatLatLong( - watch("current_installation.well")?.location?.latitude, - watch("current_installation.well")?.location?.longitude, - )} + watch("current_installation.well")?.location?.latitude, + watch("current_installation.well")?.location?.longitude, + )} {watch("current_installation.well")?.osetag ?? "--"} diff --git a/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx index 5a66924b..02a95727 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx @@ -11,10 +11,10 @@ import { } from "@mui/material"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import { Controller, useFieldArray } from "react-hook-form"; -import { ImageUploadWithPreview, StyledToggleButton } from "../../../components"; -import { NoteTypeLU } from "../../../interfaces"; -import { WorkingOnArrivalValue } from "../../../enums"; -import { useGetNoteTypes } from "../../../service/ApiServiceNew"; +import { ImageUploadWithPreview, StyledToggleButton } from "@/components"; +import { NoteTypeLU } from "@/interfaces"; +import { WorkingOnArrivalValue } from "@/enums"; +import { useGetNoteTypes } from "@/service"; export default function NotesSelection({ control, watch }: any) { const notesList = useGetNoteTypes(); @@ -134,7 +134,10 @@ export default function NotesSelection({ control, watch }: any) { name="photos" control={control} render={({ field }) => ( - field.onChange(files)} /> + field.onChange(files)} + /> )} /> diff --git a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx index 6cf8a7c4..02ddc0da 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx @@ -3,11 +3,13 @@ import { Box, Button, Grid, Typography, IconButton } from "@mui/material"; import { UseQueryResult } from "react-query"; import { Delete } from "@mui/icons-material"; import { useFieldArray, useWatch } from "react-hook-form"; -import { ObservedPropertyTypeLU } from "../../../interfaces"; -import { useGetPropertyTypes } from "../../../service/ApiServiceNew"; -import { ControlledSelectNonObject } from "../../../components/RHControlled/ControlledSelect"; -import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; +import { ObservedPropertyTypeLU } from "@/interfaces"; +import { useGetPropertyTypes } from "@/service"; +import { + ControlledSelectNonObject, + ControlledTimepicker, + ControlledTextbox, +} from "@/components"; import dayjs from "dayjs"; const ObservationRow = ({ diff --git a/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx index b0d65bcd..8c0820d2 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx @@ -10,9 +10,9 @@ import { Typography, } from "@mui/material"; import { useFieldArray } from "react-hook-form"; -import { Part } from "../../../interfaces"; -import { StyledToggleButton } from "../../../components"; -import { useGetMeterPartsList } from "../../../service/ApiServiceNew"; +import { Part } from "@/interfaces"; +import { StyledToggleButton } from "@/components"; +import { useGetMeterPartsList } from "@/service"; export default function PartsSelection({ control, watch, setValue }: any) { const partsList = useGetMeterPartsList({ diff --git a/frontend/src/views/Activities/index.ts b/frontend/src/views/Activities/index.ts new file mode 100644 index 00000000..b9f1c2f5 --- /dev/null +++ b/frontend/src/views/Activities/index.ts @@ -0,0 +1,2 @@ +export * from "./ActivitiesView"; +export * from "./ActivityPhotoView"; diff --git a/frontend/src/views/Backups/BackupsView.tsx b/frontend/src/views/Backups/BackupsView.tsx index 7868c29a..602341fc 100644 --- a/frontend/src/views/Backups/BackupsView.tsx +++ b/frontend/src/views/Backups/BackupsView.tsx @@ -12,12 +12,21 @@ import { import { Storage, Refresh, Download } from "@mui/icons-material"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { useQuery } from "react-query"; +import { Route } from "@/routes/manage/backups"; +import { useNavigate } from "@tanstack/react-router"; import { BackupRow } from "@/interfaces/BackupRow"; import { useFetchWithAuth } from "@/hooks"; -import { BackgroundBox, CustomCardHeader } from "@/components"; +import { + BackgroundBox, + CustomCardHeader, + ManageBreadcrumbTitle, +} from "@/components"; import { toYYYYMMDD, formatBytes } from "@/utils"; export const BackupsView = () => { + const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); + const fetchWithAuth = useFetchWithAuth(); const [downloading, setDownloading] = useState>({}); @@ -137,7 +146,10 @@ export const BackupsView = () => { return ( - + } + icon={Storage} + /> {error && ( { pagination: { paginationModel: { page: 0, pageSize: 25 } }, }} pageSizeOptions={[10, 25, 50, 100]} + paginationModel={{ page, pageSize }} + onPaginationModelChange={(m) => { + navigate({ + to: "/manage/backups", + search: { + page: m.page, + pageSize: m.pageSize, + }, + replace: true, // avoid polluting history on every click + }); + }} slotProps={{ toolbar: { showQuickFilter: true, diff --git a/frontend/src/views/Chlorides/ChloridesPlot.tsx b/frontend/src/views/Chlorides/ChloridesPlot.tsx index ad0e1429..a6c73401 100644 --- a/frontend/src/views/Chlorides/ChloridesPlot.tsx +++ b/frontend/src/views/Chlorides/ChloridesPlot.tsx @@ -1,9 +1,10 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; -import Plot from "react-plotly.js"; -import { Data } from "plotly.js"; +import ReactPlot from "react-plotly.js"; +import type { Data } from "plotly.js"; +import { PlotContextMenu } from "../../components/PlotContextMenu"; -export const ChloridesPlot = ({ +export const Plot = ({ manual_dates, manual_vals, isLoading, @@ -12,6 +13,25 @@ export const ChloridesPlot = ({ manual_vals: { value: number; well: string }[]; isLoading: boolean; }) => { + const plotContainerRef = useRef(null); + const plotRef = useRef(null); + const [plotRevision, setPlotRevision] = useState(0); + const [dragMode, setDragMode] = useState<"pan" | "zoom">("pan"); + + const resetAxes = () => { + if (!plotRef.current) { + return; + } + + const resetAxesButton = plotRef.current.querySelector( + '.modebar-btn[data-title="Reset axes"]', + ); + + if (resetAxesButton) { + resetAxesButton.click(); + } + }; + const data: Partial[] = useMemo(() => { const wellData: Record = {}; @@ -26,19 +46,57 @@ export const ChloridesPlot = ({ return Object.entries(wellData).map(([well, { x, y }], index) => ({ x, y, - type: "scatter", + type: "scattergl", mode: "markers", marker: { color: generateColorScale(index) }, name: `Well ${well}`, + hovertemplate: + "Date: %{x|%B %-d, %Y}
Value: %{y} ppm%{fullData.name}", })); }, [manual_dates, manual_vals]); + const hasData = data.length > 0; + + useEffect(() => { + const container = plotContainerRef.current; + if (!container) { + return undefined; + } + + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + setPlotRevision((prev) => prev + 1); + }); + }); + + observer.observe(container); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + return ( - - {isLoading ? + + {isLoading && !hasData ? ( - : - - } + ) : ( + + setDragMode((prev) => (prev === "pan" ? "zoom" : "pan")) + } + > + + { + plotRef.current = graphDiv; + }} + onUpdate={(_, graphDiv) => { + plotRef.current = graphDiv; + }} + config={{ + displaylogo: false, + responsive: true, + modeBarButtonsToRemove: [ + "select2d", + "lasso2d", + "autoScale2d", + ], + }} + useResizeHandler + style={{ width: "100%", height: "100%" }} + /> + + + )} ); }; diff --git a/frontend/src/views/Chlorides/ChloridesTable.tsx b/frontend/src/views/Chlorides/ChloridesTable.tsx index f16aeef7..e4948862 100644 --- a/frontend/src/views/Chlorides/ChloridesTable.tsx +++ b/frontend/src/views/Chlorides/ChloridesTable.tsx @@ -1,18 +1,20 @@ 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 { Add } from "@mui/icons-material"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { useIsAuthenticated } from "react-auth-kit"; +import { RegionMeasurementDTO } from "@/interfaces"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/chlorides"; dayjs.extend(utc); dayjs.extend(timezone); declare module "@mui/x-data-grid" { - interface FooterPropsOverrides extends Partial { } + interface FooterPropsOverrides extends Partial {} } interface FooterExtraProps { @@ -20,7 +22,7 @@ interface FooterExtraProps { isRegionSelected: boolean; } -export const ChloridesTable = ({ +export const Table = ({ rows, onOpenModal, isRegionSelected, @@ -44,6 +46,9 @@ export const ChloridesTable = ({ }; }) => void; }) => { + const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); + const isAuthenticated = useIsAuthenticated(); const columns: GridColDef[] = useMemo(() => { const baseCols: GridColDef[] = [ @@ -60,8 +65,7 @@ export const ChloridesTable = ({ field: "value", headerName: "Chlorides (ppm)", width: 175, - valueFormatter: (value) => - value == null ? "NOT SAMPLED" : value, + valueFormatter: (value) => (value == null ? "NOT SAMPLED" : value), }, { field: "well", @@ -90,6 +94,24 @@ export const ChloridesTable = ({ { + navigate({ + to: "/chlorides", + search: (prev) => ({ + ...(prev as any), + regionId: prev.regionId ?? undefined, + page: m.page, + pageSize: m.pageSize, + }), + replace: true, + }); + }} slots={{ footer: Footer, }} @@ -122,8 +144,8 @@ const Footer = ({ size="small" onClick={onOpenModal} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" }, ml: 1.5 }} + startIcon={} > - Create ) : null} diff --git a/frontend/src/views/Chlorides/index.ts b/frontend/src/views/Chlorides/index.ts new file mode 100644 index 00000000..1f43d6af --- /dev/null +++ b/frontend/src/views/Chlorides/index.ts @@ -0,0 +1,2 @@ +export * from "./ChloridesPlot"; +export * from "./ChloridesTable"; diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx index 29016cc7..63e745a6 100644 --- a/frontend/src/views/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -1,88 +1,331 @@ -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 { + Box, + Button, + Card, + CardContent, + CardMedia, + Grid, + Skeleton, + Stack, + Typography, +} from "@mui/material"; +import { Link } from "@tanstack/react-router"; import HomeIcon from "@mui/icons-material/Home"; -import { BackgroundBox } from "../components/BackgroundBox"; -import { CustomCardHeader } from "../components/CustomCardHeader"; +import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward"; +import AssignmentTurnedInOutlinedIcon from "@mui/icons-material/AssignmentTurnedInOutlined"; +import AutorenewOutlinedIcon from "@mui/icons-material/AutorenewOutlined"; +import BuildCircleOutlinedIcon from "@mui/icons-material/BuildCircleOutlined"; +import FactCheckOutlinedIcon from "@mui/icons-material/FactCheckOutlined"; +import MonitorHeartIcon from "@mui/icons-material/MonitorHeart"; +import ScienceIcon from "@mui/icons-material/Science"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +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 { useGetHomeSummary } from "@/service"; + +const formatStat = (value?: number) => + typeof value === "number" ? value.toLocaleString("en-US") : "0"; + +const statCards = [ + { + key: "completed_work_orders", + label: "Work Orders Completed", + icon: AssignmentTurnedInOutlinedIcon, + color: "#1f4d3a", + }, + { + key: "repairs_processed", + label: "Repairs", + icon: BuildCircleOutlinedIcon, + color: "#7c3f00", + }, + { + key: "reinstallations_processed", + label: "Meter Reinstallations", + icon: AutorenewOutlinedIcon, + color: "#0f4c81", + }, + { + key: "preventative_maintenance_processed", + label: "Preventative Maintenance Visits", + icon: FactCheckOutlinedIcon, + color: "#6a1b3f", + }, +] as const; + +const publicLinks = [ + { + title: "Chlorides", + description: + "Browse chloride measurements by region and review recent sampling data.", + to: "/chlorides", + icon: ScienceIcon, + accent: + "linear-gradient(135deg, rgba(16, 76, 129, 0.12) 0%, rgba(24, 197, 244, 0.22) 100%)", + }, + { + title: "Monitoring Wells", + description: + "Explore monitoring well readings, trends, and public well data in one place.", + to: "/monitoringwells", + icon: MonitorHeartIcon, + accent: + "linear-gradient(135deg, rgba(31, 77, 58, 0.12) 0%, rgba(105, 181, 93, 0.22) 100%)", + }, +] as const; 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", - ]; + const summaryQuery = useGetHomeSummary(); return ( - + - - - - - PVACD Meter Manager Info - Version History - - {versionHistory.map((version) => ( - - - - ))} - - - - - + + + - + > + + A complete system for managing meters, wells, and field + operations. + + + + + + + Since launch + + + {statCards.map((card) => { + const Icon = card.icon; + const value = summaryQuery.data?.[card.key]; + + return ( + + + + + + + + + {summaryQuery.isLoading ? ( + + ) : ( + formatStat(value) + )} + + + {card.label} + + + + + + ); + })} + + + + + + + + + + + + + + + + Public Data + + + Access public measurements and well monitoring data. + + + {publicLinks.map((item) => { + const Icon = item.icon; + + return ( + + + + + + + + + {item.title} + + + + {item.description} + + + + + + + + ); + })} + + + + diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index 6b0a3c7e..6651bf33 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "@tanstack/react-router"; import { useAuthUser, useIsAuthenticated, useSignIn } from "react-auth-kit"; import { Box, @@ -10,17 +10,24 @@ import { Alert, Stack, Grid, + InputAdornment, + IconButton, } from "@mui/material"; -import LoginIcon from '@mui/icons-material/Login'; +import { + Login as LoginIcon, + Visibility, + VisibilityOff, +} from "@mui/icons-material"; import { enqueueSnackbar } from "notistack"; -import { SecurityScope } from "../interfaces"; -import { API_URL } from "../config"; -import { CustomCardHeader } from "../components"; +import { SecurityScope } from "@/interfaces"; +import { API_URL } from "@/config"; +import { CustomCardHeader } from "@/components"; export const Login = () => { - const [username, setUsername] = useState(""); + const [loginIdentifier, setLoginIdentifier] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [showPassword, setShowPassword] = useState(false); const signIn = useSignIn(); const isAuthenticated = useIsAuthenticated(); @@ -31,21 +38,21 @@ export const Login = () => { event.preventDefault(); const body = new FormData(); - body.append("username", username); + body.append("username", loginIdentifier); 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." + "Unable to connect to the server. Please check your internet connection and try again. If the issue persists, contact support.", ); }); }; useEffect(() => { if (isAuthenticated()) { - navigate(authUser()?.redirect_page ?? "/"); + navigate({ to: authUser()?.redirect_page ?? "/" }); } }, [isAuthenticated, navigate]); @@ -59,7 +66,7 @@ export const Login = () => { ) { enqueueSnackbar( "Your role does not have access to the site UI. Please try accessing data via our API.", - { variant: "error" } + { variant: "error" }, ); return; } @@ -73,9 +80,9 @@ export const Login = () => { ) { localStorage.setItem("_auth", data.access_token); localStorage.setItem("loggedIn", "true"); - navigate(data.user.redirect_page ?? "/"); + navigate({ to: data.user.redirect_page ?? "/" }); } else { - setError("Invalid username or password. Please try again."); + setError("Invalid username, email, or password. Please try again."); } }); } else { @@ -95,10 +102,7 @@ export const Login = () => { }} > - + { sx={{ paddingTop: "1.5rem", paddingBottom: "1.5rem" }} > setUsername(e.target.value)} + onChange={(e) => setLoginIdentifier(e.target.value)} /> setPassword(e.target.value)} + InputProps={{ + endAdornment: ( + + setShowPassword((show) => !show)} + > + {showPassword ? : } + + + ), + }} /> @@ -164,4 +180,3 @@ export const Login = () => { }; export default Login; - diff --git a/frontend/src/views/Manage/ManageView.tsx b/frontend/src/views/Manage/ManageView.tsx new file mode 100644 index 00000000..a52978bb --- /dev/null +++ b/frontend/src/views/Manage/ManageView.tsx @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { useAuthUser } from "react-auth-kit"; +import DashboardCustomizeOutlinedIcon from "@mui/icons-material/DashboardCustomizeOutlined"; +import { Box, Card, CardContent } from "@mui/material"; +import { BackgroundBox, CustomCardHeader, NavLink } from "@/components"; +import { navConfig } from "@/constants"; +import { SecurityScope } from "@/interfaces"; + +export const ManageView = () => { + const authUser = useAuthUser(); + const scopes = useMemo( + () => + new Set( + authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string, + ) ?? [], + ), + [authUser], + ); + + const hasReadScope = scopes.has("read"); + const hasAdminScope = scopes.has("admin"); + + const manageItems = navConfig.filter((item) => { + if (!item.path.startsWith("/manage/")) return false; + if (item.role === "Technician") return hasReadScope; + if (item.role === "Admin") return hasAdminScope; + return true; + }); + + return ( + + + + + + {manageItems.map((item) => ( + + ))} + + + + + ); +}; diff --git a/frontend/src/views/Manage/index.ts b/frontend/src/views/Manage/index.ts new file mode 100644 index 00000000..8c7ae438 --- /dev/null +++ b/frontend/src/views/Manage/index.ts @@ -0,0 +1 @@ +export * from "./ManageView"; diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 231b19a8..b2fc5f4c 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -1,12 +1,9 @@ -import { useForm, SubmitHandler } from "react-hook-form"; import { useEffect, useState } from "react"; +import { useForm, SubmitHandler } from "react-hook-form"; import { enqueueSnackbar } from "notistack"; import { useAuthUser } from "react-auth-kit"; -import { createSearchParams, useNavigate } from "react-router-dom"; -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 { useNavigate } from "@tanstack/react-router"; +import { Add, Grading, Save, SaveAs } from "@mui/icons-material"; import { Button, Grid, Card, CardContent, InputAdornment } from "@mui/material"; import { Table, @@ -16,27 +13,23 @@ import { TableHead, TableRow, } from "@mui/material"; -import { SecurityScope, Meter } from "../../interfaces"; -import { - useCreateMeter, - useGetMeter, - useUpdateMeter, -} from "../../service/ApiServiceNew"; import * as Yup from "yup"; 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"; -import { formatLatLong } from "../../conversions"; -import ControlledMeterRegisterSelect from "../../components/RHControlled/ControlledMeterRegisterSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { + CustomCardHeader, + ControlledTextbox, + ControlledMeterTypeSelect, + ControlledWellSelection, + ControlledMeterStatusTypeSelect, + ControlledMeterRegisterSelect, +} from "@/components"; +import { SecurityScope, Meter } from "@/interfaces"; +import { useCreateMeter, useGetMeter, useUpdateMeter } from "@/service"; +import { formatLatLong } from "@/conversions"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), - price: Yup.number() - .nullable() - .min(0, "Price cannot be negative"), + price: Yup.number().nullable().min(0, "Price cannot be negative"), meter_type: Yup.object().required("Please select a meter type."), meter_register: Yup.object().required("Please select a meter register."), }); @@ -114,11 +107,12 @@ export const MeterDetailsFields = ({ const navigateToNewActivity = () => { navigate({ - pathname: "/activities", - search: createSearchParams({ - meter_id: selectedMeterID?.toString() ?? "", - serial_number: meterDetails.data?.serial_number ?? "", - }).toString(), + to: "/activities", + search: { + meter_id: selectedMeterID, + serial_number: meterDetails.data?.serial_number ?? undefined, + work_order_id: undefined, + }, }); }; @@ -126,7 +120,7 @@ export const MeterDetailsFields = ({ @@ -184,7 +178,9 @@ export const MeterDetailsFields = ({ type="number" inputProps={{ step: "0.01" }} InputProps={{ - startAdornment: $, + startAdornment: ( + $ + ), }} /> @@ -218,12 +214,13 @@ export const MeterDetailsFields = ({ : watch("well")?.location?.trss}
- {watch("well")?.location?.latitude == null + {!watch("well")?.location?.latitude || + !watch("well")?.location?.longitude ? "--" : formatLatLong( - watch("well")?.location?.latitude, - watch("well")?.location?.longitude, - )} + watch("well")?.location?.latitude ?? 0, + watch("well")?.location?.longitude ?? 0, + )} {watch("well")?.osetag == null @@ -292,7 +289,7 @@ export const MeterDetailsFields = ({ variant="contained" onClick={handleSubmit(onAddMeter, onErr)} > - +   Save New Meter ) : ( @@ -301,7 +298,7 @@ export const MeterDetailsFields = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index 70a108d3..2133ace5 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -1,90 +1,134 @@ import { useState, useEffect, useMemo } from "react"; import { Box, Card, CardContent, Grid } from "@mui/material"; -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 { - MeterHistoryDTO, - PatchActivityForm, - PatchObservationForm, -} from "../../../interfaces"; -import { MeterHistoryType } from "../../../enums"; +import { ImageOutlined } from "@mui/icons-material"; +import { useNavigate, useSearch } from "@tanstack/react-router"; + import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; -import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "../../../components"; -import { ImageOutlined } from "@mui/icons-material"; dayjs.extend(utc); dayjs.extend(timezone); +import { useGetMeterHistory } from "@/service"; +import { + MeterHistoryDTO, + PatchActivityForm, + PatchObservationForm, +} from "@/interfaces"; +import { MeterHistoryType } from "@/enums"; +import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "@/components"; +import { MeterHistoryTable } from "@/views/Meters/MeterHistory/MeterHistoryTable"; +import { SelectedActivityDetails } from "@/views/Meters/MeterHistory/SelectedActivityDetails"; +import { SelectedObservationDetails } from "@/views/Meters/MeterHistory/SelectedObservationDetails"; +import { SelectedBlankCard } from "@/views/Meters/MeterHistory/SelectedBlankCard"; +import { assertDefined } from "@/utils"; + export const MeterHistory = ({ selectedMeterID, }: { selectedMeterID?: number; }) => { - const location = useLocation(); - const [selectedHistoryItem, setSelectedHistoryItem] = useState(); - const meterHistory = useGetMeterHistory({ meter_id: selectedMeterID }); - const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const search = useSearch({ from: "/manage/meters" }); + + const meterHistoryQuery = useGetMeterHistory({ meter_id: selectedMeterID }); + const [dialogOpen, setDialogOpen] = useState(false); const [selectedImage, setSelectedImage] = useState(null); + const selectedActivityId = search.activity_id; + const selectedObservationId = search.observation_id; + + // Derive selected item from URL + loaded data + const selectedHistoryItem = useMemo(() => { + if (!meterHistoryQuery.data) return undefined; + + if (selectedActivityId !== undefined) { + return meterHistoryQuery.data.find( + (item) => + item.history_type === MeterHistoryType.Activity && + item.history_item.id === selectedActivityId, + ); + } + + if (selectedObservationId !== undefined) { + return meterHistoryQuery.data.find( + (item) => + item.history_type === MeterHistoryType.Observation && + item.history_item.id === selectedObservationId, + ); + } + + return undefined; + }, [meterHistoryQuery.data, selectedActivityId, selectedObservationId]); + + // If URL points to an activity, scroll to history section once data is loaded + useEffect(() => { + if (!meterHistoryQuery.data) return; + if (selectedActivityId === undefined && selectedObservationId === undefined) + return; + + document + .getElementById("meter_history") + ?.scrollIntoView({ behavior: "smooth" }); + }, [meterHistoryQuery.data, selectedActivityId, selectedObservationId]); + const photos = useMemo(() => { if (selectedHistoryItem?.history_type === MeterHistoryType.Activity) { - return ( - selectedHistoryItem.photos?.map((p: any) => p.url) ?? [] - ); + return selectedHistoryItem.photos?.map((p: any) => p.url) ?? []; } return []; }, [selectedHistoryItem]); - // If there is an activity_id in the URL, set the selectedHistoryItem to the corresponding item and scroll to it - useEffect(() => { - const activity_id = searchParams.get("activity_id") as number | null; - - if (meterHistory.data && activity_id !== null) { - // Find the history item with the corresponding 'id' - const load_history_item = meterHistory.data?.find( - (item: MeterHistoryDTO) => - item.history_item.id == activity_id && - item.history_type == MeterHistoryType.Activity, - ); - if (load_history_item) { - setSelectedHistoryItem(load_history_item); - - // Find the element with the corresponding id - const element = document.getElementById("meter_history"); - if (element) { - // Scroll to the element - element.scrollIntoView({ behavior: "smooth" }); - - // Remove the hash from the URL so that the user can switch meters without scrolling - location.hash = ""; - } else { - console.error("element not found"); - } - } - // Clear the activity_id from the URL so it doesn't interfere later - setSearchParams(); + const handleDeleteItem = () => { + // Clearing selection should clear URL too + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + activity_id: undefined, + observation_id: undefined, + }), + replace: true, + }); + }; + + const handleHistoryItemSelection = (historyItem: MeterHistoryDTO) => { + if (historyItem.history_type === MeterHistoryType.Activity) { + const id = historyItem.history_item.id; + + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + activity_id: prev.activity_id === id ? undefined : id, + observation_id: undefined, + }), + }); + return; } - }, [meterHistory.data]); // Run the effect only when meter history changes otherwise there is a race condition - function handleDeleteItem() { - setSelectedHistoryItem(undefined); - } + const id = historyItem.history_item.id; - function handleSaveItem() { - //Update the meter history - meterHistory.refetch(); - } + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + observation_id: prev.observation_id === id ? undefined : id, + activity_id: undefined, + }), + }); + }; // Function to convert MeterHistoryDTO to PatchMeterActivity function convertHistoryActivity( historyItem: MeterHistoryDTO, ): PatchActivityForm { + assertDefined( + selectedMeterID, + "No meter selected (selectedMeterID is undefined)", + ); + let activity_details: PatchActivityForm = { activity_id: historyItem.history_item.id, meter_id: selectedMeterID, @@ -134,8 +178,16 @@ export const MeterHistory = ({ return observation_details; } + const hasMeter = Boolean(search.meter_id); + const hasSelection = + Boolean(search.activity_id) || Boolean(search.observation_id); + const getDetailsCard = (historyItem?: MeterHistoryDTO): JSX.Element => { - if (!historyItem) return ; + if (!hasMeter) return <>; + + if (!hasSelection) return ; + + if (!historyItem) return ; if (historyItem.history_type === MeterHistoryType.Activity) { return ( @@ -144,10 +196,10 @@ export const MeterHistory = ({ meterHistoryQuery.refetch()} /> - {photos && photos?.length > 0 ? ( + {photos?.length > 0 ? ( @@ -176,7 +228,7 @@ export const MeterHistory = ({ meterHistoryQuery.refetch()} /> ); }; @@ -186,8 +238,11 @@ export const MeterHistory = ({ diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index cc61ee32..c2f41e67 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -1,26 +1,33 @@ import { Card, CardContent } from "@mui/material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import HistoryIcon from "@mui/icons-material/History"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/meters"; +import { History } from "@mui/icons-material"; 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"; +import { MeterHistoryType } from "@/enums"; +import { MeterHistoryDTO } from "@/interfaces"; +import { CustomCardHeader } from "@/components"; export const MeterHistoryTable = ({ onHistoryItemSelection, selectedMeterHistory, + isLoading, + selectedActivityId, + selectedObservationId, }: { - onHistoryItemSelection: Function; + onHistoryItemSelection: (item: MeterHistoryDTO) => void; selectedMeterHistory: MeterHistoryDTO[] | undefined; + isLoading: boolean; + selectedActivityId?: number; + selectedObservationId?: number; }) => { - const handleRowSelect = (rowDetails: any) => { - onHistoryItemSelection(rowDetails.row); - }; + const search = Route.useSearch(); + const navigate = useNavigate(); const columns: GridColDef[] = [ { @@ -67,15 +74,53 @@ export const MeterHistoryTable = ({ }, ]; + const rows = Array.isArray(selectedMeterHistory) ? selectedMeterHistory : []; + + // Stable row id (so selection works) + const getRowId = (row: MeterHistoryDTO) => { + if (row.history_type === MeterHistoryType.Activity) { + return `act-${row.history_item.id}`; + } + return `obs-${row.history_item.id}`; // if observations have id; adjust if not + }; + + // Selection model derived from URL activity_id + const rowSelectionModel = + selectedActivityId !== undefined + ? [`act-${selectedActivityId}`] + : selectedObservationId !== undefined + ? [`obs-${selectedObservationId}`] + : []; + return ( - + { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + h_pageSize: m.pageSize, + h_page: m.pageSize !== (prev as any).h_pageSize ? 0 : m.page, + }), + replace: true, + }); + }} + rowSelectionModel={rowSelectionModel} + disableRowSelectionOnClick={false} + onRowClick={(params) => { + onHistoryItemSelection(params.row as MeterHistoryDTO); + }} /> diff --git a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx index 821a685f..a3fe7c00 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx @@ -2,32 +2,28 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; -import SaveIcon from "@mui/icons-material/Save"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Save, Construction } from "@mui/icons-material"; import { PatchActivityForm, PatchActivitySubmit, SecurityScope, -} from "../../../interfaces"; -import { - useUpdateActivity, - useDeleteActivity, -} from "../../../service/ApiServiceNew"; +} from "@/interfaces"; +import { useUpdateActivity, useDeleteActivity } from "@/service/ApiServiceNew"; import dayjs from "dayjs"; import { enqueueSnackbar } from "notistack"; - -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; -import ControlledActivitySelect from "../../../components/RHControlled/ControlledActivitySelect"; -import ControlledUserSelect from "../../../components/RHControlled/ControlledUserSelect"; -import ControlledWellSelection from "../../../components/RHControlled/ControlledWellSelection"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; - -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"; +import { + ControlledDatepicker, + ControlledTimepicker, + ControlledActivitySelect, + ControlledUserSelect, + ControlledWellSelection, + ControlledTextbox, + NotesChipSelect, + ServicesChipSelect, + PartsChipSelect, + ControlledCheckbox, + CustomCardHeader, +} from "@/components"; export const SelectedActivityDetails = ({ selectedActivity, @@ -120,15 +116,12 @@ export const SelectedActivityDetails = ({ - + - + @@ -214,7 +204,7 @@ export const SelectedActivityDetails = ({ onClick={handleSubmit(onSaveChanges)} disabled={!hasAdminScope} > - +   Save Changes diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index f8245ba3..29918770 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -1,36 +1,99 @@ 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 { useNavigate, useSearch } from "@tanstack/react-router"; 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 +import { MeterSelection } from "@/views/Meters/MeterSelection/MeterSelection"; +import { MeterDetailsFields } from "@/views/Meters/MeterDetailsFields"; +import { MeterHistory } from "@/views/Meters/MeterHistory/MeterHistory"; +import { BackgroundBox } from "@/components"; + +const tabToIndex = (tab: "list" | "map") => (tab === "list" ? 0 : 1); +const indexToTab = (i: number): "list" | "map" => (i === 1 ? "map" : "list"); + export const MetersView = () => { - const location = useLocation(); - const [selectedMeter, setSelectedMeter] = useState(); - const [meterAddMode, setMeterAddMode] = useState(false); + const navigate = useNavigate(); + const search = useSearch({ from: "/manage/meters" }); - //Load page with a pre-selected meter as determined by query string + const selectedMeter = search.meter_id; + const meterAddMode = search.add; + const currentTab = search.tab; + const meterSearchQuery = search.q ?? ""; + const meterFilterButtons = search.filters; + + // If a meter is selected, force add mode off (and reflect in URL) useEffect(() => { - const searchParams = new URLSearchParams(location.search); - const meter_id = searchParams.get("meter_id") as number | null; + if (!selectedMeter) return; + if (search.add === false) return; - if (meter_id !== null) { - setSelectedMeter(meter_id); - } - }, [location.search]); + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + add: false, + }), + replace: true, + }); + }, [selectedMeter, search.add, navigate]); - //Always set the meterAddMode to false when a new meter is selected - useEffect(() => { - if (selectedMeter) { - setMeterAddMode(false); - } - }, [selectedMeter]); + const handleMeterSelection = (meterId?: number) => { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + meter_id: meterId, + activity_id: undefined, + observation_id: undefined, + // selecting a meter turns add off + add: meterId ? false : prev.add, + }), + }); + }; + + const handleMeterAddMode = (addMode: boolean) => { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + add: addMode, + // entering add mode clears meter selection + activity + observation + meter_id: addMode ? undefined : prev.meter_id, + activity_id: addMode ? undefined : prev.activity_id, + observation_id: addMode ? undefined : prev.observation_id, + }), + }); + }; + + const handleTabChange = (tabIndex: number) => { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + tab: indexToTab(tabIndex), + }), + }); + }; + + const handleSearchQueryChange = (query: string) => { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + q: query.trim() ? query : undefined, + }), + }); + }; + + const handleFilterButtonsChange = ( + filters: Array<"installed" | "stored" | "sold" | "scrapped" | "unknown">, + ) => { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + filters: filters.length ? filters : ["installed"], + }), + }); + }; return ( @@ -41,8 +104,14 @@ export const MetersView = () => { > diff --git a/frontend/src/views/Meters/index.ts b/frontend/src/views/Meters/index.ts new file mode 100644 index 00000000..de3ffd87 --- /dev/null +++ b/frontend/src/views/Meters/index.ts @@ -0,0 +1 @@ +export * from './MetersView' diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx index f5ac2f67..18011cd0 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx @@ -1,9 +1,10 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; -import Plot from "react-plotly.js"; -import { Data } from "plotly.js"; +import ReactPlot from "react-plotly.js"; +import type { Data } from "plotly.js"; +import { PlotContextMenu } from "../../components/PlotContextMenu"; -export const MonitoringWellsPlot = ({ +export const Plot = ({ manual_dates, manual_vals, logger_dates, @@ -11,6 +12,7 @@ export const MonitoringWellsPlot = ({ sensor_dates, sensor_vals, isLoading, + isContinuousLoading = false, }: { manual_dates: Date[]; manual_vals: number[]; @@ -19,33 +21,71 @@ export const MonitoringWellsPlot = ({ sensor_dates?: Date[]; sensor_vals?: number[]; isLoading: boolean; + isContinuousLoading?: boolean; }) => { + const plotContainerRef = useRef(null); + const plotRef = useRef(null); + const [plotRevision, setPlotRevision] = useState(0); + const [dragMode, setDragMode] = useState<"pan" | "zoom">("pan"); + + const resetAxes = () => { + if (!plotRef.current) { + return; + } + + const resetAxesButton = plotRef.current.querySelector( + '.modebar-btn[data-title="Reset axes"]', + ); + + if (resetAxesButton) { + resetAxesButton.click(); + } + }; + const data: Partial[] = useMemo( - () => [ - { - x: manual_dates, - y: manual_vals, - type: "scatter", - mode: "markers", - marker: { color: "red" }, - name: "Manual", - }, - { - x: logger_dates, - y: logger_vals, - type: "scatter", - marker: { color: "blue" }, - name: "Continuous", - }, - { - x: sensor_dates, - y: sensor_vals, - type: "scatter", - mode: "markers", - marker: { color: "purple" }, - name: "Woodpecker Sensor", - }, - ], + () => { + const traces: Partial[] = []; + + if (manual_dates.length > 0) { + traces.push({ + x: manual_dates, + y: manual_vals, + type: "scattergl", + mode: "markers", + marker: { color: "red" }, + name: "Manual", + hovertemplate: + "Date: %{x|%B %-d, %Y}
Value: %{y} ft%{fullData.name}", + }); + } + + if (logger_dates.length > 0) { + traces.push({ + x: logger_dates, + y: logger_vals, + type: "scattergl", + marker: { color: "blue" }, + name: "Continuous", + hovertemplate: + "Date: %{x|%B %-d, %Y}
Value: %{y} ft%{fullData.name}", + }); + } + + if (sensor_dates && sensor_dates.length > 0) { + traces.push({ + x: sensor_dates, + y: sensor_vals, + type: "scattergl", + mode: "markers", + marker: { color: "purple" }, + name: "Woodpecker Sensor", + hovertemplate: + "Date: %{x|%B %-d, %Y}
Value: %{y} ft%{fullData.name}", + }); + } + + return traces; + }, [ manual_dates, manual_vals, @@ -56,12 +96,48 @@ export const MonitoringWellsPlot = ({ ], ); + const hasData = data.length > 0; + + useEffect(() => { + const container = plotContainerRef.current; + if (!container) { + return undefined; + } + + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + setPlotRevision((prev) => prev + 1); + }); + }); + + observer.observe(container); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + return ( - - {isLoading ? ( + + {isLoading && !hasData ? ( ) : ( - + + + setDragMode((prev) => (prev === "pan" ? "zoom" : "pan")) + } + > + + { + plotRef.current = graphDiv; + }} + onUpdate={(_, graphDiv) => { + plotRef.current = graphDiv; + }} + config={{ + displaylogo: false, + responsive: true, + modeBarButtonsToRemove: [ + "select2d", + "lasso2d", + "autoScale2d", + ], + }} + useResizeHandler + style={{ width: "100%", height: "100%" }} + /> + + + {isContinuousLoading && ( + + + + Continuous data is still loading. More points will appear + automatically. + + + )} + )} ); diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx index 5eb2043d..85e6944e 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx @@ -1,12 +1,14 @@ 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 { Add } from "@mui/icons-material"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { useIsAuthenticated } from "react-auth-kit"; +import { MonitoredWell, WellMeasurementDTO } from "@/interfaces"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/monitoringwells"; dayjs.extend(utc); dayjs.extend(timezone); @@ -19,7 +21,7 @@ declare module "@mui/x-data-grid" { } } -export const MonitoringWellsTable = ({ +export const Table = ({ rows, onOpenModal, isWellSelected, @@ -41,6 +43,8 @@ export const MonitoringWellsTable = ({ }; }) => void; }) => { + const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); const isAuthenticated = useIsAuthenticated(); const columns: GridColDef[] = useMemo(() => { const baseCols: GridColDef[] = [ @@ -53,7 +57,12 @@ export const MonitoringWellsTable = ({ dayjs.utc(value).tz("America/Denver").format("MM/DD/YYYY hh:mm A"), type: "dateTime", }, - { field: "value", headerName: "Depth to Water (ft)", flex: 1, minWidth: 100 }, + { + field: "value", + headerName: "Depth to Water (ft)", + flex: 1, + minWidth: 100, + }, ]; // Add user column only if logged in @@ -75,6 +84,24 @@ export const MonitoringWellsTable = ({ { + navigate({ + to: "/monitoringwells", + search: (prev) => ({ + ...(prev as any), + wellId: prev.wellId ?? undefined, + page: m.page, + pageSize: m.pageSize, + }), + replace: true, + }); + }} slots={{ footer: Footer, }} @@ -122,9 +149,13 @@ const Footer = ({ size="small" onClick={onOpenModal} disabled={isPlugged} - sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" }, ml: 1.5 }} + sx={{ + flexShrink: 0, + width: { xs: "100%", sm: "auto" }, + ml: 1.5, + }} + startIcon={} > - Create diff --git a/frontend/src/views/MonitoringWells/index.ts b/frontend/src/views/MonitoringWells/index.ts new file mode 100644 index 00000000..b2e83932 --- /dev/null +++ b/frontend/src/views/MonitoringWells/index.ts @@ -0,0 +1,2 @@ +export * from "./MonitoringWellsPlot"; +export * from "./MonitoringWellsTable"; diff --git a/frontend/src/views/NotFound.tsx b/frontend/src/views/NotFound.tsx index 3d176f94..6ec85bb2 100644 --- a/frontend/src/views/NotFound.tsx +++ b/frontend/src/views/NotFound.tsx @@ -1,17 +1,17 @@ import { Box, Button, Card, CardContent, Typography } from "@mui/material"; -import DoNotTouchIcon from '@mui/icons-material/DoNotTouch'; -import { BackgroundBox, CustomCardHeader } from "../components"; -import { Link } from "react-router-dom"; -import { Home } from "@mui/icons-material"; +import { Home, DoNotTouch } from "@mui/icons-material"; +import { Link } from "@tanstack/react-router"; +import { BackgroundBox, CustomCardHeader } from "@/components"; export const NotFound = () => { return ( - + - Sorry, the page you are looking for does not exist or may have been moved. + Sorry, the page you are looking for does not exist or may have been + moved. @@ -28,4 +28,4 @@ export const NotFound = () => { ); -} +}; diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx new file mode 100644 index 00000000..bb12a4c1 --- /dev/null +++ b/frontend/src/views/Notifications.tsx @@ -0,0 +1,487 @@ +import { useEffect, useMemo, useState } from "react"; +import dayjs from "dayjs"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Checkbox, + Chip, + FormControl, + Grid, + InputAdornment, + InputLabel, + MenuItem, + Select, + TextField, +} from "@mui/material"; +import { Add, NotificationsOutlined, Search } from "@mui/icons-material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { DatePicker } from "@mui/x-date-pickers"; +import { useNavigate } from "@tanstack/react-router"; +import { useAuthUser } from "react-auth-kit"; +import { + BackgroundBox, + CreateNotificationModal, + CustomCardHeader, + TristateToggle, + UserAvatar, +} from "@/components"; +import { Notification, SecurityScope, User } from "@/interfaces"; +import { Route } from "@/routes/notifications"; +import { + useCreateNotifications, + useGetNotifications, + useGetNotificationTypes, + useGetRoles, + useUpdateNotificationReadStatus, + useGetUserAdminList, +} from "@/service"; +import { getRoleLabel } from "@/utils/UserRoleGrouping"; + +const formatNotificationTypeName = (value: string) => + value + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + +export const Notifications = () => { + const navigate = useNavigate(); + const authUser = useAuthUser(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const search = Route.useSearch(); + const isAdmin = + authUser()?.user_role?.security_scopes?.some( + (scope: SecurityScope) => scope.scope_string === "admin", + ) ?? false; + const notificationTypesQuery = useGetNotificationTypes(); + const rolesQuery = useGetRoles({ enabled: isAdmin }); + const usersQuery = useGetUserAdminList({ enabled: isAdmin }); + const createNotifications = useCreateNotifications(() => { + setIsCreateModalOpen(false); + }); + const updateNotificationReadStatus = useUpdateNotificationReadStatus(); + const notificationTypeIds = useMemo( + () => (notificationTypesQuery.data ?? []).map((type) => type.id), + [notificationTypesQuery.data], + ); + const getAvatarRole = (user: User | null | undefined) => + user ? getRoleLabel(user) : undefined; + + useEffect(() => { + if (!notificationTypeIds.length || search.notification_type_id.length) + return; + + setSearch((prev) => ({ + ...prev, + notification_type_id: notificationTypeIds, + page: 0, + })); + }, [notificationTypeIds, search.notification_type_id.length]); + + const notificationsQuery = useGetNotifications({ + q: search.q || undefined, + is_read: + search.is_read === "all" + ? undefined + : search.is_read === "true" + ? true + : false, + notification_type_id: + search.notification_type_id.length > 0 + ? search.notification_type_id + : notificationTypeIds.length > 0 + ? notificationTypeIds + : undefined, + created_from: search.created_from, + created_to: search.created_to, + limit: search.pageSize, + offset: search.page * search.pageSize, + }); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/notifications", + search: (prev) => updater(prev as typeof search), + replace: true, + }); + }; + + const columns = useMemo[]>( + () => { + const baseColumns: GridColDef[] = [ + { + field: "read_toggle", + headerName: "Mark Read", + minWidth: 110, + flex: 0.7, + sortable: false, + filterable: false, + renderCell: (params) => ( + + updateNotificationReadStatus.mutate({ + id: params.row.id, + is_read: checked, + }) + } + /> + ), + }, + { + field: "created_at", + headerName: "Created", + minWidth: 190, + flex: 1.1, + valueFormatter: (value) => + value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", + }, + { + field: "notification_type", + headerName: "Type", + minWidth: 140, + flex: 0.9, + sortable: false, + valueGetter: (_, row) => row.notification_type?.name ?? "", + renderCell: (params) => ( + + ), + }, + { + field: "is_read", + headerName: "Status", + minWidth: 110, + flex: 0.7, + renderCell: (params) => ( + + ), + }, + { + field: "title", + headerName: "Title", + minWidth: 220, + flex: 1.4, + }, + { + field: "message", + headerName: "Message", + minWidth: 320, + flex: 2.3, + }, + { + field: "link", + headerName: "Link", + minWidth: 180, + flex: 1.2, + sortable: false, + renderCell: (params) => { + const value = params.value as string | null | undefined; + if (!value) return "-"; + + return ( +
+ Open + + ); + }, + }, + ]; + + if (!isAdmin) return baseColumns; + + return [ + baseColumns[0], + baseColumns[1], + { + field: "creator", + headerName: "Created By", + minWidth: 220, + flex: 1.3, + sortable: false, + cellClassName: "notification-creator-cell", + valueGetter: (_, row) => + row.creator?.display_name || row.creator?.full_name || "", + renderCell: (params) => { + const creator = params.row.creator; + if (!creator) return "-"; + + const name = creator.display_name || creator.full_name || "Unknown"; + + return ( + + + {name} + + ); + }, + }, + ...baseColumns.slice(2), + ]; + }, + [getAvatarRole, isAdmin, updateNotificationReadStatus], + ); + + return ( + + + + + + + + setSearch((prev) => ({ + ...prev, + created_from: + value && value.isValid() + ? value.format("YYYY-MM-DD") + : undefined, + page: 0, + })) + } + views={["year", "month", "day"]} + openTo="year" + format="YYYY MMMM DD" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + + + + setSearch((prev) => ({ + ...prev, + created_to: + value && value.isValid() + ? value.format("YYYY-MM-DD") + : undefined, + page: 0, + })) + } + views={["year", "month", "day"]} + openTo="year" + format="YYYY MMMM DD" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + + + + Type + + + + + + + setSearch((prev) => ({ + ...prev, + q: e.target.value, + page: 0, + })) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + setSearch((prev) => ({ + ...prev, + is_read: next, + page: 0, + })) + } + /> + + + + {notificationsQuery.error ? ( + + Failed to load notifications. + + ) : null} + + + + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } + disableRowSelectionOnClick + disableColumnMenu + getRowHeight={() => "auto"} + sx={{ + "& .notification-creator-cell": { + alignItems: "flex-start", + py: 1, + }, + "& .MuiDataGrid-cell": { + py: 1.25, + }, + }} + /> + + + + + + {isAdmin ? ( + + + + ) : null} + + {isAdmin ? ( + setIsCreateModalOpen(false)} + users={usersQuery.data ?? []} + roles={rolesQuery.data ?? []} + notificationTypes={notificationTypesQuery.data ?? []} + loading={createNotifications.isLoading} + onSubmit={(payload) => createNotifications.mutate(payload)} + /> + ) : null} + + + + ); +}; diff --git a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx index a4c2d567..c0a7df86 100644 --- a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx +++ b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx @@ -1,22 +1,18 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { Alert, Button, Card, CardContent, Grid } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; +import { Add, Edit, Save, SaveAs } from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; +import { useCreateMeterType, useUpdateMeterType } from "@/service"; import { - useCreateMeterType, - useUpdateMeterType, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import { MeterTypeLU } from "../../interfaces"; -import { ControlledSelectNonObject } from "../../components/RHControlled/ControlledSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; + ControlledTextbox, + ControlledSelectNonObject, + CustomCardHeader, +} from "@/components"; +import { MeterTypeLU } from "@/interfaces"; const MeterTypeResolverSchema: Yup.ObjectSchema = Yup.object().shape({ brand: Yup.string().required("Please enter a brand."), @@ -82,7 +78,7 @@ export const MeterTypeDetailsCard = ({ @@ -143,7 +139,7 @@ export const MeterTypeDetailsCard = ({ variant="contained" onClick={handleSubmit(onAddPart, onErr)} > - +   Save New Meter Type ) : ( @@ -152,7 +148,7 @@ export const MeterTypeDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index 9ece1568..2766b630 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, @@ -10,26 +10,35 @@ import { TextField, Typography, } from "@mui/material"; -import { Search } from "@mui/icons-material"; -import { useGetMeterTypeList } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { MeterTypeLU } from "../../interfaces"; -import TristateToggle from "../../components/TristateToggle"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { IsTrueChip, CustomCardHeader } from "../../components"; +import { Search, Add, SpeedOutlined } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { useGetMeterTypeList } from "@/service"; +import { Route } from "@/routes/manage/parts/index"; +import { + CustomCardHeader, + GridFooterWithButton, + IsTrueChip, + TristateToggle, +} from "@/components"; export const MeterTypesTable = ({ - setSelectedMeterType, - setMeterTypeAddMode, + onSelectMeterType, + onCreateMeterType, }: { - setSelectedMeterType: Function; - setMeterTypeAddMode: Function; + onSelectMeterType: (id: number) => void; + onCreateMeterType: () => void; }) => { const meterTypes = useGetMeterTypeList(); - const [meterTypeSearchQuery, setMeterTypeSearchQuery] = useState(""); - const [filteredRows, setFilteredRows] = useState(); - const [inUseFilter, setInUseFilter] = useState(); + const navigate = useNavigate(); + const search = Route.useSearch(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/parts", + search: (prev) => updater(prev as any), + replace: true, + }); + }; const cols: GridColDef[] = [ { field: "brand", headerName: "Brand", width: 200 }, @@ -44,43 +53,57 @@ export const MeterTypesTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => + renderCell: (params: any) => , }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid - useEffect(() => { - const psq = meterTypeSearchQuery.toLowerCase(); - let filtered = (meterTypes.data ?? []).filter( + const filteredRows = useMemo(() => { + const q = (search.meter_type_q ?? "").toLowerCase(); + let rows = (meterTypes.data ?? []).filter( (row) => - row.brand?.toLowerCase().includes(psq) || - row.model?.toLowerCase().includes(psq) || - row.size?.toString().includes(psq) || - row.series?.toLowerCase().includes(psq) || - row.description?.toLowerCase().includes(psq), + row.brand?.toLowerCase().includes(q) || + row.model?.toLowerCase().includes(q) || + row.size?.toString().includes(q) || + row.series?.toLowerCase().includes(q) || + row.description?.toLowerCase().includes(q), ); - if (inUseFilter != undefined) - filtered = filtered.filter((row) => row.in_use == inUseFilter); - setFilteredRows(filtered); - }, [meterTypeSearchQuery, meterTypes.data, inUseFilter]); + if (search.meter_type_in_use !== "all") { + const wantInUse = search.meter_type_in_use === "true"; + rows = rows.filter((row) => row.in_use === wantInUse); + } + + return rows; + }, [meterTypes.data, search.meter_type_q, search.meter_type_in_use]); return ( - + - + setMeterTypeSearchQuery(event.target.value)} + value={search.meter_type_q ?? ""} + onChange={(event: any) => + setSearch((prev) => ({ + ...prev, + meter_type_q: event.target.value, + mt_page: 0, + })) + } InputProps={{ startAdornment: ( @@ -90,11 +113,29 @@ export const MeterTypesTable = ({ }} /> - - Choose Filters: + + + Choose Filters:{" "} + setInUseFilter(state)} + value={search.meter_type_in_use} + onToggle={(next) => + setSearch((prev) => ({ + ...prev, + meter_type_in_use: next, + mt_page: 0, + })) + } /> @@ -102,11 +143,32 @@ export const MeterTypesTable = ({ + setSearch((prev) => ({ + ...prev, + mt_pageSize: model.pageSize, + mt_page: model.pageSize !== prev.mt_pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50, 100]} + rowSelectionModel={ + search.meter_type_id ? [search.meter_type_id] : [] + } loading={meterTypes.isLoading} columns={cols} disableColumnMenu onRowClick={(selectedRow) => { - setSelectedMeterType(selectedRow.row); + if (search.meter_type_id === selectedRow.row.id) { + onCreateMeterType(); + return; + } + + onSelectMeterType(selectedRow.row.id); }} slots={{ footer: GridFooterWithButton }} slotProps={{ @@ -115,17 +177,20 @@ export const MeterTypesTable = ({ @@ -136,6 +201,6 @@ export const MeterTypesTable = ({ /> - + ); }; diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index e403a0b1..7a4e80c7 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -15,31 +15,28 @@ import { OutlinedInput, Select, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; -import CancelIcon from "@mui/icons-material/Cancel"; +import { Add, Cancel, Edit, Save, SaveAs } from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; import { useFieldArray } from "react-hook-form"; - import { useCreatePart, useGetMeterTypeList, useGetPart, useUpdatePart, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import ControlledPartTypeSelect from "../../components/RHControlled/ControlledPartTypeSelect"; -import { MeterTypeLU, Part } from "../../interfaces"; -import { ControlledSelectNonObject } from "../../components/RHControlled/ControlledSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +} from "@/service"; +import { + ControlledTextbox, + ControlledPartTypeSelect, + ControlledSelectNonObject, + CustomCardHeader, +} from "@/components"; +import { MeterTypeLU, Part } from "@/interfaces"; const PartResolverSchema: Yup.ObjectSchema = Yup.object().shape({ part_number: Yup.string().required("Please enter a part number."), - count: Yup.number() + initial_count: Yup.number() .typeError("Please enter a number.") .required("Please enter a count."), part_type: Yup.mixed().required("Please select a part type."), @@ -129,7 +126,7 @@ export const PartDetailsCard = ({ @@ -172,11 +169,11 @@ export const PartDetailsCard = ({ @@ -187,7 +184,9 @@ export const PartDetailsCard = ({ type="number" inputProps={{ step: "0.01" }} InputProps={{ - startAdornment: $, + startAdornment: ( + $ + ), }} /> @@ -229,7 +228,7 @@ export const PartDetailsCard = ({ label={`${value.brand} - ${value.model}`} clickable deleteIcon={ - event.stopPropagation() } @@ -271,7 +270,7 @@ export const PartDetailsCard = ({ variant="contained" onClick={handleSubmit(onAddPart, onErr)} > - +   Save New Part ) : ( @@ -280,7 +279,7 @@ export const PartDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/Parts/PartsHistory.tsx b/frontend/src/views/Parts/PartsHistory.tsx new file mode 100644 index 00000000..47980660 --- /dev/null +++ b/frontend/src/views/Parts/PartsHistory.tsx @@ -0,0 +1,876 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Link as RouterLink, + useNavigate, + useParams, +} from "@tanstack/react-router"; +import { + Breadcrumbs, + Box, + Card, + CardContent, + Grid, + Typography, + TextField, + Button, + InputAdornment, + Snackbar, + Alert, + Link as MuiLink, +} from "@mui/material"; +import { + Build, + DashboardCustomizeOutlined, + History, + InfoOutlined, + NavigateNext, + PlusOne, + Save, + Search, +} from "@mui/icons-material"; +import { + DataGrid, + GridColDef, + GridFooter, + GridFooterContainer, +} from "@mui/x-data-grid"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import dayjs, { Dayjs } from "dayjs"; +import { + CustomCardHeader, + BackgroundBox, + EventTypeChip, + ControlledDatepicker, + ControlledSelectNonObject, + IncreaseQuantityModal, + RouterMuiLink, +} from "@/components"; +import { + useAddParts, + useGetPartHistory, + useGetParts, + useUpdatePartHistory, +} from "@/service"; +import { useForm } from "react-hook-form"; +import { DateTimePicker } from "@mui/x-date-pickers"; +import { Route } from "@/routes/manage/parts/$id/history"; +import { + EditablePartHistoryRow, + PartHistoryResponse, +} from "@/interfaces/PartHistoryResponse"; +import { useSnackbar } from "notistack"; + +type EventType = "initial" | "used" | "added" | "current"; +const EVENT_TYPE_ORDER: EventType[] = ["initial", "used", "added", "current"]; + +type PartsHistoryFormValues = { + from?: Dayjs | null; + to: Dayjs; + event_types: EventType[]; +}; + +const schema = yup.object().shape({ + from: yup.mixed().nullable(), + 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)); + }), + event_types: yup + .array() + .of(yup.string().oneOf(["initial", "used", "added", "current"]).required()) + .min(1, "Select at least one event type") + .required(), +}); + +const defaultSchema = { + from: null, + to: dayjs().endOf("month"), + event_types: [...EVENT_TYPE_ORDER] as EventType[], +}; + +function normalizeEventTypes(input: unknown): EventType[] { + const values = Array.isArray(input) ? input : []; + const set = new Set(values); + return EVENT_TYPE_ORDER.filter((type) => set.has(type)); +} + +function sameStringArray(a: string[], b: string[]) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function recalculateRows(sourceRows: any[]) { + const initialRow = sourceRows.find((row) => row.event_type === "initial"); + const currentRow = sourceRows.find((row) => row.event_type === "current"); + const historyRows = sourceRows + .filter( + (row) => row.event_type !== "initial" && row.event_type !== "current", + ) + .sort((a, b) => { + const dateDiff = + new Date(a.event_date).getTime() - new Date(b.event_date).getTime(); + if (dateDiff !== 0) return dateDiff; + return Number(a.ref_id ?? 0) - Number(b.ref_id ?? 0); + }); + + let running = Number(initialRow?.total_after ?? 0); + const nextRows = initialRow ? [{ ...initialRow, total_after: running }] : []; + + historyRows.forEach((row) => { + running += Number(row.delta ?? 0); + nextRows.push({ ...row, total_after: running }); + }); + + if (currentRow) { + nextRows.push({ ...currentRow, total_after: running }); + } + + return nextRows; +} + +function hydrateRows(data: PartHistoryResponse, partId?: string) { + const raw = data.history ?? []; + const currentRow = + data.current_count != null + ? { + row_id: `current-${partId ?? "unknown"}`, + part_id: Number(partId), + event_date: dayjs().toISOString(), + event_type: "current", + ref_id: null, + note: "Current count", + delta: 0, + total_after: data.current_count, + work_order_id: null, + } + : null; + + return recalculateRows(currentRow ? [...raw, currentRow] : raw); +} + +const PartsHistoryBreadcrumbTitle = ({ + partNumber = "", + partId, +}: { + partId?: string; + partNumber?: string; +}) => { + return ( + } + sx={{ + color: "inherit", + "& .MuiBreadcrumbs-ol": { + alignItems: "center", + }, + "& .MuiBreadcrumbs-separator": { + display: "inline-flex", + alignItems: "center", + color: "rgba(255, 255, 255, 0.72)", + mx: 1, + }, + }} + > + + + Manage + + + + Parts + + {partNumber && partId && ( + + + {partNumber} + + )} + + History + + + ); +}; + +export const PartsHistory = () => { + const { id } = useParams({ from: "/manage/parts/$id/history" }); + const navigate = useNavigate(); + const search = Route.useSearch(); + const history = useGetPartHistory(id); + const partsList = useGetParts(); + const addParts = useAddParts(); + const { enqueueSnackbar } = useSnackbar(); + const updateHistory = useUpdatePartHistory(id, (response) => { + const nextRows = hydrateRows(response, id); + setRows(nextRows); + setOriginalRows(nextRows); + setHasChanges(false); + }); + + const [rows, setRows] = useState([]); + const [originalRows, setOriginalRows] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + const [increaseOpen, setIncreaseOpen] = useState(false); + const [snackbar, setSnackbar] = useState<{ + message: string; + severity: "success" | "error"; + } | null>(null); + const isApplyingSearchToFormRef = useRef(false); + + const { control, watch, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + + const defaultValues = useMemo(() => { + const normalizedTypes = normalizeEventTypes(search.type); + return { + from: search.from ? dayjs(search.from, "YYYY-MM-DD") : null, + to: search.to ? dayjs(search.to, "YYYY-MM-DD") : dayjs().endOf("month"), + event_types: + normalizedTypes.length > 0 + ? normalizedTypes + : defaultSchema.event_types, + }; + }, [search.from, search.to, search.type]); + + useEffect(() => { + isApplyingSearchToFormRef.current = true; + reset(defaultValues); + queueMicrotask(() => { + isApplyingSearchToFormRef.current = false; + }); + }, [defaultValues, reset]); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/parts/$id/history", + params: { id }, + search: (prev) => updater(prev as typeof search), + replace: true, + }); + }; + + const from = watch("from"); + const to = watch("to"); + const eventTypes = watch("event_types"); + + useEffect(() => { + if (!history.data) return; + + const nextRows = hydrateRows(history.data, id); + setRows(nextRows); + setOriginalRows(nextRows); + setHasChanges(false); + }, [history.data, id]); + + const filteredRows = useMemo(() => { + const q = (search.q ?? "").trim().toLowerCase(); + const fromDate = from ? dayjs(from).startOf("day") : null; + const toDate = to ? dayjs(to).endOf("day") : null; + const selectedTypes = new Set((eventTypes ?? []) as string[]); + + return rows.filter((r: any) => { + if (selectedTypes.size && !selectedTypes.has(r.event_type)) return false; + if (q && !(r.note ?? "").toLowerCase().includes(q)) return false; + + if (r.event_type === "initial" || r.event_type === "current") { + return selectedTypes.has(r.event_type); + } + + const d = dayjs(r.event_date); + if (fromDate && d.isBefore(fromDate)) return false; + if (toDate && d.isAfter(toDate)) return false; + return true; + }); + }, [rows, search.q, from, to, eventTypes]); + + useEffect(() => { + if (isApplyingSearchToFormRef.current) return; + + const nextFrom = from ? from.format("YYYY-MM-DD") : undefined; + const nextTo = (to ?? dayjs().endOf("month")).format("YYYY-MM-DD"); + const nextTypes = normalizeEventTypes( + eventTypes ?? defaultSchema.event_types, + ); + const currentTypes = normalizeEventTypes(search.type); + + const sameFrom = search.from === nextFrom; + const sameTo = search.to === nextTo; + const sameTypes = sameStringArray(currentTypes, nextTypes); + + if (sameFrom && sameTo && sameTypes) return; + + navigate({ + to: "/manage/parts/$id/history", + params: { id }, + search: (prev) => ({ + ...(prev as typeof search), + from: nextFrom, + to: nextTo, + type: nextTypes, + page: 0, + }), + replace: true, + }); + }, [from, to, eventTypes, id, navigate, search.from, search.to, search.type]); + + const processRowUpdate = useCallback((newRow: any, _oldRow: any) => { + if (newRow.delta !== undefined && isNaN(Number(newRow.delta))) { + throw new Error("Delta must be a number"); + } + + if (newRow.event_type === "used") { + const deltaNum = Number(newRow.delta ?? 0); + + if (deltaNum >= 0) { + throw new Error( + "Delta for work order usage ('used') must be negative (parts removed)", + ); + } + } + + if (newRow.event_type === "added" && Number(newRow.delta ?? 0) <= 0) { + throw new Error("Delta for added parts must be positive"); + } + + setRows((prevRows) => + recalculateRows( + prevRows.map((r) => + r.row_id === newRow.row_id + ? { + ...newRow, + delta: Number(newRow.delta ?? 0), + note: newRow.note ?? null, + } + : r, + ), + ), + ); + + setHasChanges(true); + return newRow; + }, []); + + const handleSave = async () => { + try { + const changedRows = rows + .filter( + (row) => row.event_type === "added" || row.event_type === "used", + ) + .filter((row) => { + const originalRow = originalRows.find( + (candidate) => candidate.row_id === row.row_id, + ); + + if (!originalRow) return false; + + return ( + Number(originalRow.delta) !== Number(row.delta) || + (originalRow.note ?? "") !== (row.note ?? "") || + !dayjs(originalRow.event_date).isSame(dayjs(row.event_date)) + ); + }) + .map( + (row): EditablePartHistoryRow => ({ + ref_id: Number(row.ref_id), + event_type: row.event_type, + event_date: dayjs(row.event_date).toISOString(), + note: row.note ?? null, + delta: Number(row.delta), + }), + ); + + if (!changedRows.length) { + setHasChanges(false); + return; + } + + await updateHistory.mutateAsync({ rows: changedRows }); + setSnackbar({ + message: "Changes saved successfully", + severity: "success", + }); + } catch { + setSnackbar({ message: "Failed to save changes", severity: "error" }); + } + }; + + const handleDiscard = () => { + setRows(originalRows); + setHasChanges(false); + setSnackbar({ message: "Changes discarded", severity: "success" }); + }; + + const cols: GridColDef[] = [ + { + field: "event_date", + headerName: "Date", + width: 250, + editable: true, + renderCell: (params) => { + const row = params.row; + if (row.event_type === "initial") return "-"; + + const d = + row.event_type === "current" ? new Date() : new Date(params.value); + + return isNaN(d.getTime()) + ? String(params.value) + : dayjs(d).format("MMM D, YYYY h:mm A"); + }, + renderEditCell: (params) => { + const { id, value, api } = params; + + return ( + { + if (newValue) { + api.setEditCellValue({ + id, + field: "event_date", + value: newValue.toISOString(), + }); + } + }} + slotProps={{ + textField: { + variant: "outlined", + size: "small", + autoFocus: true, + fullWidth: true, + }, + }} + format="MMM D, YYYY h:mm A" + /> + ); + }, + sortComparator: (a, b) => { + // keep Initial at top + if (a === "Initial") return -1; + if (b === "Initial") return 1; + return new Date(a).getTime() - new Date(b).getTime(); + }, + }, + { + field: "event_type", + headerName: "Type", + width: 140, + renderCell: (params) => ( + + ), + }, + { + field: "delta", + headerName: "Change", + width: 140, + editable: true, + renderCell: (params) => { + const n = Number(params.value ?? 0); + const label = `${n > 0 ? "+" : ""}${n}`; + return ( + + {label} + + ); + }, + preProcessEditCellProps: (params) => { + const hasError = + params.row.event_type === "used" && + Number(params.props.value ?? 0) >= 0; + + return { ...params.props, error: hasError }; + }, + }, + { + field: "total_after", + headerName: "Total After", + width: 160, + renderCell: (params) => ( + + {params.value} + + ), + }, + { + field: "work_order_id", + headerName: "Work Order", + width: 140, + renderCell: (params) => + params.value ? ( + + WO {params.value} + + ) : ( + "N/A" + ), + }, + { + field: "note", + editable: true, + headerName: "Note", + flex: 1, + minWidth: 240, + cellClassName: (params) => + params.row.event_type === "initial" || + params.row.event_type === "current" + ? "disabled-cell" + : "", + }, + ]; + + return ( + + + + } + icon={History} + /> + + + + + + + + + + + opt === "used" + ? "Work Orders" + : opt === "added" + ? "Parts Added" + : opt === "current" + ? "Current" + : "Initial" + } + /> + + + + setSearch((prev) => ({ + ...prev, + q: e.target.value, + page: 0, + })) + } + sx={{ width: "100%" }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + row.row_id} + loading={history.isLoading} + columns={cols} + pagination + paginationModel={{ + page: search.page, + pageSize: search.pageSize, + }} + onPaginationModelChange={(model) => + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50, 100]} + processRowUpdate={processRowUpdate} + disableRowSelectionOnClick + disableColumnMenu + disableColumnFilter + hideFooterSelectedRowCount + editMode="row" + isCellEditable={(params) => { + return ( + params.row.event_type !== "initial" && + params.row.event_type !== "current" + ); + }} + initialState={{ + sorting: { + sortModel: [{ field: "event_date", sort: "asc" }], + }, + }} + slots={{ + footer: () => { + return ( + + + {hasChanges && ( + <> + + + + + )} + + + + ); + }, + }} + /> + + + + + + + + + + + setSnackbar(null)} + > + setSnackbar(null)}> + {snackbar?.message} + + + setIncreaseOpen(false)} + parts={partsList.data ?? []} + defaultPartId={id ? Number(id) : undefined} + loading={addParts.isLoading} + onSubmit={(payload) => { + addParts.mutate(payload, { + onSuccess: async () => { + enqueueSnackbar("Quantity increase submitted successfully.", { + variant: "success", + }); + setIncreaseOpen(false); + await Promise.all([partsList.refetch(), history.refetch()]); + }, + onError: () => { + enqueueSnackbar( + "Failed to submit quantity increase. Please try again.", + { + variant: "error", + }, + ); + }, + }); + }} + /> + + ); +}; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 8d4f74bc..a470a848 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -1,37 +1,59 @@ -import { useEffect, useState } from "react"; +import { useMemo, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import dayjs from "dayjs"; import { + Box, Button, Card, CardContent, Grid, + IconButton, InputAdornment, Stack, TextField, Typography, } from "@mui/material"; -import { Search } from "@mui/icons-material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { useGetParts } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import { Part } from "../../interfaces"; -import TristateToggle from "../../components/TristateToggle"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; -import { IsTrueChip } from "../../components"; +import { + PlusOne, + Search, + Add, + History, + Build, +} from "@mui/icons-material"; +import { useSnackbar } from "notistack"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useGetParts, useAddParts } from "@/service"; +import { Route } from "@/routes/manage/parts/index"; +import { + CustomCardHeader, + GridFooterWithButton, + IncreaseQuantityModal, + IsTrueChip, + ManageBreadcrumbTitle, + TristateToggle, +} from "@/components"; export const PartsTable = ({ - setSelectedPartID, - setPartAddMode, + onSelectPart, + onCreatePart, }: { - setSelectedPartID: Function; - setPartAddMode: Function; + onSelectPart: (id: number) => void; + onCreatePart: () => void; }) => { const partsList = useGetParts(); - const [partSearchQuery, setPartSearchQuery] = useState(""); - const [filteredRows, setFilteredRows] = useState(); - const [inUseFilter, setInUseFilter] = useState(); - const [commonlyUsedFilter, setCommonlyUsedFilter] = useState(); + const addParts = useAddParts(); + const navigate = useNavigate(); + const search = Route.useSearch(); + const [increaseOpen, setIncreaseOpen] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/parts", + search: (prev) => updater(prev as any), + replace: true, + }); + }; const cols: GridColDef[] = [ { field: "part_number", headerName: "Part Number", width: 150 }, @@ -42,54 +64,117 @@ export const PartsTable = ({ width: 200, valueGetter: (params: any) => params?.name, }, - { field: "count", headerName: "Count" }, + { + field: "current_count", + headerName: "Current Count", + width: 150, + renderCell: (params: any) => ( + + {params.value} + e.stopPropagation()} + onClick={(e: any) => e.stopPropagation()} + > + + + + + + ), + }, { field: "in_use", headerName: "In Use", - renderCell: (params: any) => + renderCell: (params: any) => , }, { field: "commonly_used", headerName: "Commonly Used", - renderCell: (params: any) => + renderCell: (params: any) => , }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid - useEffect(() => { - const psq = partSearchQuery.toLowerCase(); - let filtered = (partsList.data ?? []).filter( + const filteredRows = useMemo(() => { + const q = (search.part_q ?? "").toLowerCase(); + let rows = (partsList.data ?? []).filter( (row) => - row.part_number.toLowerCase().includes(psq) || - row.description?.toLowerCase().includes(psq) || - row.part_type?.name.toLowerCase().includes(psq), + row.part_number.toLowerCase().includes(q) || + row.description?.toLowerCase().includes(q) || + row.part_type?.name.toLowerCase().includes(q), ); - if (inUseFilter != undefined) - filtered = filtered.filter((row) => row.in_use == inUseFilter); - if (commonlyUsedFilter != undefined) - filtered = filtered.filter( - (row) => row.commonly_used == commonlyUsedFilter, - ); - setFilteredRows(filtered); - }, [partSearchQuery, partsList.data, inUseFilter, commonlyUsedFilter]); + if (search.part_in_use !== "all") { + const wantInUse = search.part_in_use === "true"; + rows = rows.filter((row) => row.in_use === wantInUse); + } + + if (search.part_commonly_used !== "all") { + const wantCommonlyUsed = search.part_commonly_used === "true"; + rows = rows.filter((row) => row.commonly_used === wantCommonlyUsed); + } + + return rows; + }, [ + partsList.data, + search.part_q, + search.part_in_use, + search.part_commonly_used, + ]); return ( } + icon={Build} /> - + setPartSearchQuery(event.target.value)} + value={search.part_q ?? ""} + onChange={(event: any) => + setSearch((prev) => ({ + ...prev, + part_q: event.target.value, + p_page: 0, + })) + } InputProps={{ startAdornment: ( @@ -99,16 +184,39 @@ export const PartsTable = ({ }} /> - - Choose Filters: + + + Choose Filters:{" "} + setInUseFilter(state)} + value={search.part_in_use} + onToggle={(next) => + setSearch((prev) => ({ + ...prev, + part_in_use: next, + p_page: 0, + })) + } /> - setCommonlyUsedFilter(state) + value={search.part_commonly_used} + onToggle={(next) => + setSearch((prev) => ({ + ...prev, + part_commonly_used: next, + p_page: 0, + })) } /> @@ -116,11 +224,31 @@ export const PartsTable = ({ + setSearch((prev) => ({ + ...prev, + p_pageSize: model.pageSize, + p_page: + model.pageSize !== prev.p_pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50, 100]} + rowSelectionModel={search.part_id ? [search.part_id] : []} loading={partsList.isLoading} columns={cols} disableColumnMenu onRowClick={(selectedRow) => { - setSelectedPartID(selectedRow.row.id); + if (search.part_id === selectedRow.row.id) { + onCreatePart(); + return; + } + + onSelectPart(selectedRow.row.id); }} slots={{ footer: GridFooterWithButton }} slotProps={{ @@ -129,17 +257,49 @@ export const PartsTable = ({ + ), @@ -150,6 +310,39 @@ export const PartsTable = ({ + setIncreaseOpen(false)} + parts={partsList.data ?? []} + loading={addParts.isLoading} + onSubmit={(payload) => { + addParts.mutate( + { + part_id: payload.part_id, + count: payload.count, + date: payload.date, + note: payload.note, + }, + { + onSuccess: () => { + enqueueSnackbar("Quantity increase submitted successfully.", { + variant: "success", + }); + setIncreaseOpen(false); + partsList.refetch(); + }, + onError: () => { + enqueueSnackbar( + "Failed to submit quantity increase. Please try again.", + { + variant: "error", + }, + ); + }, + }, + ); + }} + /> ); }; diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index 18b4d7ab..adda642a 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -1,55 +1,83 @@ -import { useEffect, useState } from "react"; -import { PartsTable } from "./PartsTable"; import { Grid } from "@mui/material"; +import { useNavigate } from "@tanstack/react-router"; +import { BackgroundBox } from "@/components"; +import { useGetMeterTypeList } from "@/service"; +import { Route } from "@/routes/manage/parts/index"; + +import { PartsTable } from "./PartsTable"; +import { MeterTypeDetailsCard } from "./MeterTypeDetailsCard"; import { PartDetailsCard } from "./PartDetailsCard"; import { MeterTypesTable } from "./MeterTypesTable"; -import { MeterTypeDetailsCard } from "./MeterTypeDetailsCard"; -import { MeterTypeLU } from "../../interfaces"; -import { BackgroundBox } from "../../components/BackgroundBox"; export const PartsView = () => { - const [selectedPartID, setSelectedPartID] = useState(); - const [partAddMode, setPartAddMode] = useState(true); - const [selectedMeterType, setSelectedMeterType] = useState(); - const [meterTypeAddMode, setMeterTypeAddMode] = useState(true); + const navigate = useNavigate(); + const search = Route.useSearch(); + const meterTypes = useGetMeterTypeList(); - // Exit add mode when part is selected from table - useEffect(() => { - if (selectedPartID) setPartAddMode(false); - }, [selectedPartID]); + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/parts", + search: (prev) => updater(prev as any), + replace: true, + }); + }; - useEffect(() => { - if (selectedMeterType) setMeterTypeAddMode(false); - }, [selectedMeterType]); + const selectedMeterType = meterTypes.data?.find( + (meterType) => meterType.id === search.meter_type_id, + ); return ( + setSearch((prev) => ({ + ...prev, + part_id: id, + part_add: false, + })) + } + onCreatePart={() => + setSearch((prev) => ({ + ...prev, + part_id: undefined, + part_add: true, + })) + } /> + setSearch((prev) => ({ + ...prev, + meter_type_id: id, + meter_type_add: false, + })) + } + onCreateMeterType={() => + setSearch((prev) => ({ + ...prev, + meter_type_id: undefined, + meter_type_add: true, + })) + } /> - + ); }; diff --git a/frontend/src/views/Parts/index.ts b/frontend/src/views/Parts/index.ts new file mode 100644 index 00000000..57f2e9fe --- /dev/null +++ b/frontend/src/views/Parts/index.ts @@ -0,0 +1,2 @@ +export * from "./PartsView"; +export * from "./PartsHistory"; diff --git a/frontend/src/views/Reports/Board/index.tsx b/frontend/src/views/Reports/Board/index.tsx deleted file mode 100644 index c7932f33..00000000 --- a/frontend/src/views/Reports/Board/index.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { ArrowBack, People, PictureAsPdf } from "@mui/icons-material"; -import { - Box, - Button, - Card, - CardContent, - CardHeader, - Grid, - IconButton, - Tooltip, -} from "@mui/material"; -import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { useForm } from "react-hook-form"; -import * as yup from "yup"; -import { yupResolver } from "@hookform/resolvers/yup"; -import dayjs from "dayjs"; - -const schema = yup.object().shape({ - from: yup.mixed().nullable().required("From date is required"), - to: yup.mixed().nullable().required("To date is required"), -}); - -const defaultSchema = { - from: dayjs(), - to: dayjs(), -}; - -export const BoardReportView = () => { - const { control, reset } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultSchema, - }); - - return ( - - - - Board Report - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index eeff2111..6f81e691 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,5 +1,5 @@ -import { useEffect } from "react"; -import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; +import { useEffect, useMemo, useRef } from "react"; +import { PictureAsPdf, ScienceOutlined } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; import { useAuthHeader } from "react-auth-kit"; @@ -8,7 +8,6 @@ import { Card, CardContent, Grid, - IconButton, Tooltip, Typography, Alert, @@ -17,43 +16,70 @@ import { Divider, Box, } from "@mui/material"; -import { LayersControl, MapContainer, Marker, Tooltip as MapTooltip } from "react-leaflet"; -import { Link } from "react-router-dom"; +import { + LayersControl, + MapContainer, + Marker, + Tooltip as MapTooltip, +} from "react-leaflet"; +import { useNavigate } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import L from "leaflet"; -import { API_URL } from "../../../config"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer, SatelliteLayer, OpenStreetMapLayer, WellMapLegend } from "../../../components"; -import { useFetchWithAuth } from "../../../hooks"; -import { useGetWellLocations } from "../../../service/ApiServiceNew"; -import { Well } from "../../../interfaces"; -import { RedMapIcon, BlackMapIcon } from "../../../components/MapIcons"; -import { WellStatus } from "../../../enums"; +import { Route } from "@/routes/reports/chlorides"; +import { API_URL } from "@/config"; +import { + ControlledDatepicker, + CustomCardHeader, + BackgroundBox, + DirectionCard, + MapFullscreenToggle, + MapUrlStateSync, + ReportBreadcrumbTitle, + SoutheastGuideLayer, + SatelliteLayer, + OpenStreetMapLayer, + WellMapLegend, + TransporationLayer, + BoundariesLayer, +} from "@/components"; +import { RedMapIcon, BlackMapIcon } from "@/components/MapIcons"; +import { useFetchWithAuth } from "@/hooks"; +import { useGetWellLocations } from "@/service"; +import { Well } from "@/interfaces"; +import { WellStatus } from "@/enums"; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + getMapLayersControlKey, + normalizeMapBaseLayer, + normalizeMapOverlayNames, + parseMapView, +} from "@/utils"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "leaflet/dist/leaflet.css"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; +type FormValues = { + from: Dayjs; + to: Dayjs; +}; + 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) { + .test("is-after", "'To' date must be after 'From'", function (value) { const { from } = this.parent; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), }); -const defaultSchema = { - from: dayjs().startOf('month'), - to: dayjs().endOf('month'), -}; - interface iMinMaxAvgMedCount { min?: number; max?: number; @@ -69,15 +95,81 @@ interface iChlorideReportNums { west: iMinMaxAvgMedCount; } +const isoToDayjs = (s?: string, fallback?: Dayjs) => + s ? dayjs(s, "YYYY-MM-DD") : (fallback ?? dayjs()); + +const BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; +const OVERLAY_NAMES = [ + "Wells", + "Clorides Report Region Guide", + "Transportation", + "Boundaries and Places", +] as const; +const DEFAULT_BASE_LAYER = "OpenStreetMap"; +const DEFAULT_OVERLAYS = ["Clorides Report Region Guide", "Wells"]; + export const ChloridesReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + const mapContainerRef = useRef(null); + const mapBaseLayer = normalizeMapBaseLayer( + search.mapBase, + BASE_LAYER_NAMES, + DEFAULT_BASE_LAYER, + ); + const mapOverlayNames = normalizeMapOverlayNames( + search.mapOverlays, + OVERLAY_NAMES, + DEFAULT_OVERLAYS, + ); + const mapView = parseMapView(search, { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }); + + const defaultValues = useMemo(() => { + const fallbackFrom = dayjs().startOf("month"); + const fallbackTo = dayjs().endOf("month"); + + return { + from: isoToDayjs(search.from, fallbackFrom), + to: isoToDayjs(search.to, fallbackTo), + }; + }, [search.from, search.to]); + const { control, reset, watch } = useForm({ resolver: yupResolver(schema), - defaultValues: defaultSchema, + defaultValues, }); + // If user hits back/forward or URL is edited, keep form in sync + useEffect(() => { + reset(defaultValues, { keepDirty: false, keepTouched: false }); + }, [defaultValues, reset]); + const from = watch("from"); const to = watch("to"); + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/chlorides", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + // form -> URL + useEffect(() => { + if (!from || !to) return; + + setSearch((prev) => ({ + ...(prev as any), + from: from.format("YYYY-MM-DD"), + to: to.format("YYYY-MM-DD"), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [from?.valueOf(), to?.valueOf()]); + const authHeader = useAuthHeader(); const fetchWithAuth = useFetchWithAuth(); @@ -92,19 +184,13 @@ export const ChloridesReportView = () => { return fetchWithAuth({ method: "GET", route: `/chlorides/report?${searchParams.toString()}`, - }) + }); }, enabled: !!from && !!to, }); const downloadPDFMutation = useMutation({ - mutationFn: async ({ - from, - to, - }: { - from: Dayjs; - to: Dayjs; - }) => { + mutationFn: async ({ from, to }: { from: Dayjs; to: Dayjs }) => { const params = new URLSearchParams({ from_date: from?.format("YYYY-MM-DD"), to_date: to?.format("YYYY-MM-DD"), @@ -140,7 +226,7 @@ export const ChloridesReportView = () => { }); }; - const wellQuery = useGetWellLocations('', true); + const wellQuery = useGetWellLocations("", true); useEffect(() => { if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { @@ -154,44 +240,16 @@ export const ChloridesReportView = () => { } + icon={ScienceOutlined} /> - - - - - - - - - - - - - - - - - - { format="YYYY MMMM DD" /> + + + + + + + { sx={{ py: 3, px: 2 }} > - Chlorides Reading: + + Chlorides Reading: + {chloridesQuery.isLoading && ( {[0, 1, 2, 3].map((i) => ( - + - + @@ -247,7 +344,8 @@ export const ChloridesReportView = () => { )} {chloridesQuery.isError && ( - {chloridesQuery.error?.message || "Failed to load chloride readings."} + {chloridesQuery.error?.message || + "Failed to load chloride readings."} )} {!chloridesQuery.isLoading && !chloridesQuery.isError && ( @@ -296,27 +394,55 @@ export const ChloridesReportView = () => { )} - + - + + {/* Base Layers */} - - - + + + {/* Wells Cluster Overlay */} - + { @@ -360,34 +488,57 @@ export const ChloridesReportView = () => { ))} + + + {/* Loading first page */} {wellQuery.isLoading && ( - Loading well markers... + + Loading well markers... + )} {/* Loading additional pages */} {wellQuery.isFetchingNextPage && ( - Loading more wells... + + Loading more wells... + )} {wellQuery.isSuccess && wellMarkers.length === 0 && ( - + No wells found for that search. @@ -395,10 +546,14 @@ export const ChloridesReportView = () => { {/* Error */} {wellQuery.isError && ( - + Failed to load wells: {wellQuery.error.message} @@ -406,12 +561,32 @@ export const ChloridesReportView = () => { - - + + - + ); }; diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index a3fa4d18..de71c819 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -1,5 +1,6 @@ -import { useMemo } from "react"; -import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; +import { useEffect, useMemo, useRef } from "react"; +import { useAuthHeader } from "react-auth-kit"; +import { ConstructionOutlined, PictureAsPdf } from "@mui/icons-material"; import { Box, Button, @@ -7,24 +8,17 @@ import { CardContent, Chip, Grid, - IconButton, TextField, Tooltip, Typography, } from "@mui/material"; -import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledAutocomplete from "../../../components/RHControlled/ControlledAutocomplete"; +import { useNavigate } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; +import { Route } from "@/routes/reports/maintenance"; import dayjs, { Dayjs } from "dayjs"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; -import { BackgroundBox } from "../../../components/BackgroundBox"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { useAuthHeader } from "react-auth-kit"; -import { API_URL } from "../../../config"; import { PieChart } from "@mui/x-charts"; import { DataGrid, @@ -32,17 +26,27 @@ import { GridValueGetter, GridValueFormatter, } from "@mui/x-data-grid"; +import { + ControlledDatepicker, + ControlledAutocomplete, + BackgroundBox, + ControlledTextbox, + CustomCardHeader, + ReportBreadcrumbTitle, + UserAvatar, +} from "@/components"; +import { API_URL, ROLE_IDS } from "@/config"; +import { User } from "@/interfaces"; +import { + getRoleLabel, + sortUsersByRoleThenName, +} from "@/utils/UserRoleGrouping"; -interface User { - full_name: string; - id: number; -} - -const ALL_TECHNICIANS_ID = -1; - -const allTechniciansOption: User = { - id: ALL_TECHNICIANS_ID, - full_name: "All Technicians", +type FormValues = { + from: Dayjs; + to: Dayjs; + techicians: User[]; // keep your existing form name + trss: string; }; const schema = yup.object().shape({ @@ -70,12 +74,20 @@ const schema = yup.object().shape({ const defaultSchema = { from: dayjs().startOf("month"), to: dayjs().endOf("month"), - techicians: [{ ...allTechniciansOption }], + techicians: [], trss: "", }; +const isoToDayjs = (s?: string, fallback?: Dayjs) => + s ? dayjs(s, "YYYY-MM-DD") : (fallback ?? dayjs()); + export const MaintenanceReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); const authHeader = useAuthHeader(); + + const hydratedRef = useRef(false); + const techiciansQuery = useQuery({ queryKey: ["users"], queryFn: async () => { @@ -95,42 +107,120 @@ export const MaintenanceReportView = () => { refetchOnReconnect: false, }); + const technicianOptions = useMemo(() => { + const users = (techiciansQuery.data ?? []) as User[]; + return sortUsersByRoleThenName(users); + }, [techiciansQuery.data]); + + // URL -> RHF default values (technicians are hydrated after users load) + const defaultValues = useMemo(() => { + const fallbackFrom = dayjs().startOf("month"); + const fallbackTo = dayjs().endOf("month"); + + return { + from: isoToDayjs(search.from, fallbackFrom), + to: isoToDayjs(search.to, fallbackTo), + techicians: [], // hydrate from search.technicians once we have users + trss: search.trss ?? "", + }; + }, [search.from, search.to, search.trss]); + const { control, reset, setValue, watch } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, }); + // keep form in sync if URL changes (back/forward) + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + // hydrate selected techs from URL ids AFTER users load + useEffect(() => { + const users = techiciansQuery.data; + if (!users) return; + + const ids = new Set(search.technicians ?? []); + let selected = users.filter((u: User) => ids.has(u.id)); + + // if no ids provided, default to "all techs" + if (selected.length === 0) + selected = users?.filter( + (u: User) => u?.user_role_id === ROLE_IDS.TECHNICIAN, + ); + + setValue("techicians", selected, { + shouldDirty: false, + shouldValidate: true, + }); + + hydratedRef.current = true; + }, [techiciansQuery.data, search.technicians, setValue]); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/maintenance", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + const from = watch("from"); const to = watch("to"); const technicians = watch("techicians"); const trss = watch("trss"); - const technicianOptions = useMemo(() => { - const base = techiciansQuery.data ?? []; - return [...base, allTechniciansOption]; - }, [techiciansQuery.data]); + // push form -> URL (but only after hydration so we don't wipe URL on refresh) + useEffect(() => { + if (!hydratedRef.current) return; + + setSearch((prev) => ({ + ...(prev as any), + from: from?.format("YYYY-MM-DD"), + to: to?.format("YYYY-MM-DD"), + trss: trss?.trim() || undefined, + technicians: (technicians ?? []).map((t) => t.id), + page: 0, // reset paging on filter changes + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + from?.valueOf(), + to?.valueOf(), + trss, + (technicians ?? []).map((t) => t.id).join(","), + ]); const dataQuery = useQuery({ queryKey: [ "maintenance", { - from: from?.format("YYYY-MM-DD"), - to: to?.format("YYYY-MM-DD"), - trss: trss ?? "", - technicians: technicians?.map((t) => t.id) ?? [], + from: search.from, + to: search.to, + trss: search.trss ?? "", + technicians: search.technicians ?? [], + offset: search.page * search.pageSize, + limit: search.pageSize, }, ], queryFn: async () => { const queryParams = new URLSearchParams(); - queryParams.set("from_date", from?.format("YYYY-MM-DD")); - queryParams.set("to_date", to?.format("YYYY-MM-DD")); - queryParams.set("trss", trss ?? ""); + queryParams.set( + "from_date", + search.from ?? dayjs().startOf("month").format("YYYY-MM-DD"), + ); + queryParams.set( + "to_date", + search.to ?? dayjs().endOf("month").format("YYYY-MM-DD"), + ); + queryParams.set("trss", search.trss ?? ""); + + (search.technicians ?? []).forEach((id) => + queryParams.append("technicians", id.toString()), + ); - technicians - ?.map((t) => t.id) - .forEach((id) => { - queryParams.append("technicians", id.toString()); - }); + // if your API supports pagination: + queryParams.set("offset", String(search.page * search.pageSize)); + queryParams.set("limit", String(search.pageSize)); const response = await fetch( `${API_URL}/maintenance?${queryParams.toString()}`, @@ -138,16 +228,13 @@ export const MaintenanceReportView = () => { headers: { Authorization: authHeader() }, }, ); - - if (!response.ok) { - throw new Error("Failed to fetch maintenance data"); - } - + if (!response.ok) throw new Error("Failed to fetch maintenance data"); return response.json(); }, - staleTime: 1000 * 60 * 60 * 24, - cacheTime: 1000 * 60 * 60 * 24, - enabled: Boolean(from && to && technicians && technicians.length > 0), + enabled: Boolean( + search.from && search.to && (search.technicians?.length ?? 0) > 0, + ), + keepPreviousData: true, }); const numberOfRepairsPieChartData = useMemo(() => { @@ -195,6 +282,12 @@ export const MaintenanceReportView = () => { ); }, [dataQuery.data]); + const techniciansByName = useMemo(() => { + return new Map( + technicianOptions.map((user) => [user.full_name, user] as const), + ); + }, [technicianOptions]); + const columns: GridColDef[] = [ { field: "date_time", @@ -210,18 +303,59 @@ export const MaintenanceReportView = () => { return date.toLocaleString(); }) as GridValueFormatter, }, - { field: "technician", headerName: "Technician", flex: 1 }, + { + field: "technician", + headerName: "Technician", + flex: 1, + renderCell: (params) => { + const technicianName = String(params.value ?? ""); + const user = techniciansByName.get(technicianName); + + return ( + + + + {technicianName} + + + ); + }, + }, { field: "number_of_repairs", headerName: "Number of Repairs", type: "number", flex: 1, + align: "left", + headerAlign: "left", }, { field: "number_of_pms", headerName: "Number of Preventative Maintenances", type: "number", flex: 1, + align: "left", + headerAlign: "left", }, { field: "meter", @@ -287,33 +421,11 @@ export const MaintenanceReportView = () => { return ( - + } + icon={ConstructionOutlined} + /> - - - - - - - - - - - - - - - - - - { format="YYYY MMMM DD" /> - + { control={control} /> + + + + + + + { option?.id === value?.id } onChange={(_: React.SyntheticEvent, selected: User[]) => { - const isSelectingAll = selected.some( - (tech) => tech.id === ALL_TECHNICIANS_ID, - ); - const allTechs = techiciansQuery.data ?? []; + setValue("techicians", selected, { + shouldDirty: true, + shouldValidate: true, + }); - if (isSelectingAll) { - // Set all real users as selected, excluding the synthetic "All Technicians" - setValue("techicians", allTechs); - } else { - setValue("techicians", selected); + if (hydratedRef.current) { + setSearch((prev) => ({ + ...(prev as any), + technicians: selected.map((t) => t.id), + page: 0, + })); } }} + groupBy={(option: User) => getRoleLabel(option)} + renderOption={(props: React.HTMLAttributes, option: User) => ( + + + + {option.full_name} + + + )} renderInput={(params: Parameters[0]) => { if (techiciansQuery.isLoading && params.inputProps) { params.inputProps.value = "Loading..."; @@ -392,6 +553,14 @@ export const MaintenanceReportView = () => { + } {...getTagProps({ index })} /> )) @@ -482,22 +651,60 @@ export const MaintenanceReportView = () => { - + { + navigate({ + to: "/reports/maintenance", + search: (prev) => ({ + ...(prev as any), + pageSize: m.pageSize, + page: m.pageSize !== (prev as any).pageSize ? 0 : m.page, + }), + replace: true, + }); }} + rowCount={dataQuery.data?.total ?? tableRows.length} /> - - + + diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index fc93959b..92d68d82 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ -import { useMemo, useEffect } from "react"; -import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; +import { useMemo, useEffect, useRef } from "react"; +import { PictureAsPdf, MonitorHeartOutlined } from "@mui/icons-material"; import { Box, Button, @@ -12,7 +12,6 @@ import { FormGroup, FormHelperText, Grid, - IconButton, InputLabel, ListSubheader, MenuItem, @@ -23,36 +22,50 @@ import { Typography, useTheme, } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { LineChart } from "@mui/x-charts"; import { css } from "@emotion/react"; -import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledAutocomplete from "../../../components/RHControlled/ControlledAutocomplete"; +import { useNavigate } from "@tanstack/react-router"; +import { useAuthHeader } from "react-auth-kit"; 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 { BackgroundBox } from "../../../components/BackgroundBox"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { - LineChart, -} from "@mui/x-charts"; -import { MonitoredWell, WellMeasurementDTO } from "../../../interfaces"; -import { useFetchWithAuth } from "../../../hooks"; -import { separateAndSortMonitoredWells } from "../../../utils"; -import { API_URL } from "../../../config"; -import { useAuthHeader } from "react-auth-kit"; - + ControlledDatepicker, + ControlledAutocomplete, + BackgroundBox, + CustomCardHeader, + ReportBreadcrumbTitle, +} from "@/components"; +import { MonitoredWell, WellMeasurementDTO } from "@/interfaces"; +import { ReportAveragesResponse } from "@/interfaces/ReportAveragesResponse"; +import { useFetchWithAuth } from "@/hooks"; +import { separateAndSortMonitoredWells } from "@/utils"; +import { API_URL } from "@/config"; +import { Route } from "@/routes/reports/monitoringwells"; + +type FormValues = { + from: Dayjs; + to: Dayjs; + wells: (MonitoredWell & { group?: string | null })[]; + isAveragingAllWells: boolean; + isComparingTo1970Average: boolean; + comparisonYear: number | null; +}; -const schema = yup.object().shape({ - from: yup.mixed().nullable().required("From date is required"), +const schema = yup.object({ + from: yup + .mixed() + .test("is-dayjs", "From date is required", (v) => dayjs.isDayjs(v)) + .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; + .test("is-dayjs", "To date is required", (v) => dayjs.isDayjs(v)) + .required() + .test("is-after", "'To' date must be after 'From'", function (value) { + const { from } = this.parent as FormValues; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), wells: yup @@ -67,24 +80,63 @@ const schema = yup.object().shape({ outside_recorder: yup.boolean().nullable(), chloride_group_id: yup.number().nullable(), group: yup.string().nullable(), - }) + }), ) - .min(1, "At least one Well is required"), + .min(1, "At least one Well is required") + .required(), isAveragingAllWells: yup.boolean().required(), isComparingTo1970Average: yup.boolean().required(), - comparisonYear: yup.number().nullable(), + comparisonYear: yup.number().nullable().default(null), }); -const defaultSchema = { - from: dayjs().startOf('month'), - to: dayjs().endOf('month'), +const isoToDayjs = (s?: string, fallback?: Dayjs) => + s ? dayjs(s, "YYYY-MM-DD") : (fallback ?? dayjs()); +const dayjsToIso = (d?: dayjs.Dayjs | null) => + d ? d.format("YYYY-MM-DD") : undefined; + +const hardResetFormValues: FormValues = { + from: dayjs().startOf("month"), + to: dayjs().endOf("month"), wells: [], isAveragingAllWells: false, isComparingTo1970Average: false, comparisonYear: null, }; +const hardResetSearch = { + from: hardResetFormValues.from.format("YYYY-MM-DD"), + to: hardResetFormValues.to.format("YYYY-MM-DD"), + well_ids: [] as number[], + avgAll: false, + cmp1970: false, + cmpYear: undefined as number | undefined, + m_page: 0, + m_pageSize: 5, + a_page: 0, + a_pageSize: 5, +}; + export const MonitoringWellsReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + + const hydratedRef = useRef(false); + + // URL -> form defaults + const defaultValues = useMemo(() => { + const fallbackFrom = dayjs().startOf("month"); + const fallbackTo = dayjs().endOf("month"); + + return { + from: isoToDayjs(search.from, fallbackFrom), + to: isoToDayjs(search.to, fallbackTo), + wells: [], // hydrate later + isAveragingAllWells: search.avgAll, + isComparingTo1970Average: search.cmp1970, + comparisonYear: search.cmpYear ?? null, + }; + }, [search.from, search.to, search.avgAll, search.cmp1970, search.cmpYear]); + const theme = useTheme(); const baseStyle = css`   font-weight: 500; @@ -95,29 +147,33 @@ export const MonitoringWellsReportView = () => {   transition: background-color 0.2s ease; `; const selectedStyle = (isOutside: boolean, theme: any) => css` - background-color: ${isOutside + background-color: ${isOutside ? theme.palette.secondary.dark : theme.palette.primary.dark} !important; - color: ${isOutside + color: ${isOutside ? theme.palette.secondary.contrastText : theme.palette.primary.contrastText} !important; - font-weight: 500; -`; + font-weight: 500; + `; const hoverStyle = (isOutside: boolean, theme: any) => css` - &:hover { - background-color: ${isOutside - ? theme.palette.secondary.main - : theme.palette.primary.main} !important; - color: ${isOutside - ? theme.palette.secondary.contrastText - : theme.palette.primary.contrastText} !important; - } -`; + &:hover { + background-color: ${isOutside + ? theme.palette.secondary.main + : theme.palette.primary.main} !important; + color: ${isOutside + ? theme.palette.secondary.contrastText + : theme.palette.primary.contrastText} !important; + } + `; const authHeader = useAuthHeader(); const fetchWithAuth = useFetchWithAuth(); - const monitoredWellsQuery = useQuery<{ items: MonitoredWell[] }, Error, MonitoredWell[]>({ + const monitoredWellsQuery = useQuery< + { items: MonitoredWell[] }, + Error, + MonitoredWell[] + >({ queryKey: ["wells"], queryFn: () => fetchWithAuth({ @@ -132,26 +188,82 @@ export const MonitoringWellsReportView = () => { select: (res) => res.items, }); - const { control, reset, watch, setValue } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultSchema, + const { control, reset, watch, setValue } = useForm({ + resolver: yupResolver(schema) as any, + defaultValues, }); - const wells = watch("wells"); - const wellIds = useMemo(() => wells?.map(w => w.id) ?? [], [wells]); + useEffect(() => { + if (!monitoredWellsQuery.data) return; + + const idSet = new Set(search.well_ids ?? []); + const selected = groupedWells.filter((w) => idSet.has(w.id)); + + setValue("wells", selected, { shouldDirty: false, shouldValidate: true }); + + // mark hydrated AFTER we apply the URL -> form + hydratedRef.current = true; + }, [monitoredWellsQuery.data, search.well_ids, setValue]); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/monitoringwells", + search: (prev) => updater(prev as any), + replace: true, + }); + }; const from = watch("from"); const to = watch("to"); - const isAveragingAllWells = watch('isAveragingAllWells'); - const isComparingTo1970Average = watch('isComparingTo1970Average'); - const comparisonYear = watch('comparisonYear'); + useEffect(() => { + const fromIso = dayjsToIso(from); + const toIso = dayjsToIso(to); + + setSearch((prev) => ({ + ...(prev as any), + from: fromIso ?? prev.from, + to: toIso ?? prev.to, + // reset paginations when filters change + m_page: 0, + a_page: 0, + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [from?.valueOf(), to?.valueOf()]); + + const wells = watch("wells"); + const wellIds = useMemo(() => wells?.map((w) => w.id) ?? [], [wells]); useEffect(() => { - if (((wells?.length ?? 0) < 2) && isAveragingAllWells) { - setValue("isAveragingAllWells", false, { shouldDirty: true, shouldValidate: true }); - } - }, [wells, isAveragingAllWells, setValue]); + // Don't overwrite URL before we've hydrated from it + if (!hydratedRef.current) return; + + setSearch((prev) => ({ + ...(prev as any), + well_ids: wellIds.length ? wellIds : [], + m_page: 0, + a_page: 0, + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wellIds.join(",")]); + + const avgAll = watch("isAveragingAllWells"); + const cmp1970 = watch("isComparingTo1970Average"); + const cmpYear = watch("comparisonYear"); + + useEffect(() => { + if (!hydratedRef.current) return; + + setSearch((prev) => ({ + ...(prev as any), + avgAll: !!avgAll, + cmp1970: !!cmp1970, + cmpYear: cmpYear ? Number(cmpYear) : undefined, + m_page: 0, + a_page: 0, + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [avgAll, cmp1970, cmpYear]); const manualMeasurementsQuery = useQuery({ queryKey: [ @@ -159,17 +271,17 @@ export const MonitoringWellsReportView = () => { wellIds, from, to, - isAveragingAllWells, - isComparingTo1970Average, - comparisonYear + avgAll, + cmp1970, + cmpYear, ], queryFn: () => { const searchParams = new URLSearchParams({ from_date: from?.format("YYYY-MM-DD"), to_date: to?.format("YYYY-MM-DD"), - isAveragingAllWells: isAveragingAllWells.toString(), - isComparingTo1970Average: isComparingTo1970Average.toString(), - comparisonYear: comparisonYear ? comparisonYear.toString() : "" + isAveragingAllWells: avgAll.toString(), + isComparingTo1970Average: cmp1970.toString(), + comparisonYear: cmpYear ? cmpYear.toString() : "", }); wellIds.forEach((id: number) => { @@ -179,31 +291,61 @@ export const MonitoringWellsReportView = () => { return fetchWithAuth({ method: "GET", route: `/waterlevels?${searchParams.toString()}`, - }) + }); + }, + enabled: wellIds.length > 0 && !!from && !!to, + }); + + const reportAveragesQuery = useQuery({ + queryKey: ["reportAverages", wellIds, from, to], + queryFn: () => { + const params = new URLSearchParams({ + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), + }); + + wellIds.forEach((id: number) => params.append("well_ids", id.toString())); + + return fetchWithAuth({ + method: "GET", + route: `/waterlevels/report-averages?${params.toString()}`, + }); }, enabled: wellIds.length > 0 && !!from && !!to, }); const columns: GridColDef[] = [ - { field: "date_time", headerName: "Date / Time", flex: 1 }, { - field: "depth_to_water", - headerName: "Depth To Water (ft)", - type: "number", + field: "well", + headerName: "Well", flex: 1, }, { - field: "well", - headerName: "Well", + field: "date_time", + headerName: "Date / Time", + flex: 1, + valueFormatter: (date) => { + if (!date) return "—"; + return dayjs(date).format("MMM D, YYYY h:mm A"); + }, + }, + { + field: "depth_to_water", + headerName: "Depth To Water (ft)", + type: "number", flex: 1, }, ]; - const tableRows = manualMeasurementsQuery?.data?.map((manualMeasurement: WellMeasurementDTO) => ({ - id: manualMeasurement.id, - date_time: manualMeasurement.timestamp, - depth_to_water: manualMeasurement.value, - well: manualMeasurement.well.ra_number, - })) ?? []; + + const tableRows = + manualMeasurementsQuery?.data?.map( + (manualMeasurement: WellMeasurementDTO) => ({ + id: manualMeasurement.id, + date_time: manualMeasurement.timestamp, + depth_to_water: manualMeasurement.value, + well: manualMeasurement.well.ra_number, + }), + ) ?? []; const groupedByWell = useMemo(() => { const groups: Record = {}; @@ -224,7 +366,9 @@ export const MonitoringWellsReportView = () => { // Timeshift ONLY the comparison series that are actually enabled/selected const shouldShift = (isComparingTo1970Average && seriesYear === 1970) || - (comparisonYear !== undefined && !Number.isNaN(comparisonYear) && seriesYear === comparisonYear); + (comparisonYear !== undefined && + !Number.isNaN(comparisonYear) && + seriesYear === comparisonYear); if (shouldShift) { const d = dayjs(timestamp); @@ -249,7 +393,7 @@ export const MonitoringWellsReportView = () => { entries.forEach((e) => { const ts = new Date(e.x).getTime(); if (!isNaN(ts)) timestamps.add(ts); - }) + }), ); return Array.from(timestamps).sort((a, b) => a - b); }, [groupedByWell]); @@ -257,7 +401,7 @@ export const MonitoringWellsReportView = () => { const series = useMemo(() => { return Object.entries(groupedByWell).map(([wellName, entries]) => { const dataMap = new Map( - entries.map((e) => [new Date(e.x).getTime(), e.y]) + entries.map((e) => [new Date(e.x).getTime(), e.y]), ); const data = allTimestamps.map((ts) => { const value = dataMap.get(ts); @@ -271,12 +415,71 @@ export const MonitoringWellsReportView = () => { }); }, [groupedByWell, allTimestamps]); - const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells(monitoredWellsQuery?.data); + const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells( + monitoredWellsQuery?.data, + ); const groupedWells = [ - ...regularWells.map(well => ({ ...well, group: "Wells" })), - ...outsideRecorderWells.map(well => ({ ...well, group: "Outside Recorder Wells" })), + ...regularWells.map((well) => ({ ...well, group: "Wells" })), + ...outsideRecorderWells.map((well) => ({ + ...well, + group: "Outside Recorder Wells", + })), + ]; + + const allWellsLatest = useMemo(() => { + const rows = reportAveragesQuery.data?.all_wells ?? []; + if (!rows.length) return null; + // assume period_start sorts ascending as ISO; if not, sort + const sorted = [...rows].sort( + (a, b) => + dayjs(a.period_start).valueOf() - dayjs(b.period_start).valueOf(), + ); + return sorted[sorted.length - 1]; + }, [reportAveragesQuery.data]); + + const bucketLabel = + reportAveragesQuery.data?.bucket === "year" ? "Year" : "Month"; + + const bucket = reportAveragesQuery.data?.bucket ?? "month"; + + const formatPeriodLabel = (periodStart: string) => { + const d = dayjs(periodStart); + if (!d.isValid()) return periodStart; + + return bucket === "year" ? d.format("YYYY") : d.format("MMM YYYY"); + }; + + const avgColumns: GridColDef[] = [ + { + field: "well", + headerName: "Well", + flex: 1, + }, + { + field: "period", + headerName: bucket === "year" ? "Year" : "Month", + flex: 1, + sortComparator: (a, b) => dayjs(a).valueOf() - dayjs(b).valueOf(), + }, + { + field: "avg", + headerName: "Average Depth To Water (ft)", + type: "number", + flex: 1, + valueFormatter: (avg?: number | null) => + typeof avg === "number" ? avg?.toFixed(2) : "—", + }, ]; + const avgRows = + reportAveragesQuery.data?.per_well?.map((r) => ({ + id: `${r.well_id}-${r.period_start}`, + period_start: r.period_start, // keep raw + period: formatPeriodLabel(r.period_start), + well: r.ra_number, + avg: r.avg_value, + })) ?? []; + const downloadPDFMutation = useMutation({ mutationFn: async ({ from, @@ -284,7 +487,7 @@ export const MonitoringWellsReportView = () => { wellIds, isAveragingAllWells, isComparingTo1970Average, - comparisonYear + comparisonYear, }: { from: Dayjs; to: Dayjs; @@ -298,7 +501,7 @@ export const MonitoringWellsReportView = () => { to_date: to?.format("YYYY-MM-DD"), isAveragingAllWells: isAveragingAllWells.toString(), isComparingTo1970Average: isComparingTo1970Average.toString(), - comparisonYear + comparisonYear, }); wellIds.forEach((id) => params.append("well_ids", id.toString())); @@ -327,55 +530,32 @@ export const MonitoringWellsReportView = () => { from, to, wellIds, - isAveragingAllWells, - isComparingTo1970Average, - comparisonYear: comparisonYear ? comparisonYear.toString() : "" + isAveragingAllWells: avgAll, + isComparingTo1970Average: cmp1970, + comparisonYear: cmpYear ? cmpYear.toString() : "", }); }; // 1971 → current year const years = Array.from( { length: new Date().getFullYear() - 1971 + 1 }, - (_, i) => 1971 + i + (_, i) => 1971 + i, ); return ( - + } + icon={MonitorHeartOutlined} + /> - - - - - - - - - - - - - - - - - - { format="YYYY MMMM DD" /> + + + + + + + option.group} - getOptionLabel={(option: MonitoredWell) => option?.name ?? "Unnamed Well"} - isOptionEqualToValue={(a: MonitoredWell, b: MonitoredWell) => a.id === b.id} + groupBy={(option: MonitoredWell & { group: string }) => + option.group + } + getOptionLabel={(option: MonitoredWell) => + option?.name ?? "Unnamed Well" + } + isOptionEqualToValue={(a: MonitoredWell, b: MonitoredWell) => + a.id === b.id + } disableClearable={false} multiple renderGroup={(params: any) => ( @@ -435,26 +645,33 @@ export const MonitoringWellsReportView = () => { )} renderTags={(value: MonitoredWell[], getTagProps: any) => - (value as (MonitoredWell & { group: string })[]).map((option, index) => { - const isOutside = option.group === "Outside Recorder Wells"; - return ( - - ); - }) + (value as (MonitoredWell & { group: string })[]).map( + (option, index) => { + const isOutside = + option.group === "Outside Recorder Wells"; + return ( + + ); + }, + ) } - renderOption={(props: any, option: MonitoredWell & { group: string }, { selected }: { selected: boolean }) => { + renderOption={( + props: any, + option: MonitoredWell & { group: string }, + { selected }: { selected: boolean }, + ) => { const isOutside = option.group === "Outside Recorder Wells"; return ( { /> - + { render={({ field: { value, onChange } }) => ( onChange(e.target.checked)} />} + control={ + { + const next = e.target.checked; + + // Only prevent enabling when <2 wells. + if (next && (wells?.length ?? 0) < 2) return; + + onChange(next); + }} + /> + } label="Average DTWs across all wells" /> )} @@ -532,7 +761,9 @@ export const MonitoringWellsReportView = () => { inputRef={field.ref} displayEmpty MenuProps={{ - PaperProps: { style: { maxHeight: 48 * 6.5 + 8, width: 220 } }, + PaperProps: { + style: { maxHeight: 48 * 6.5 + 8, width: 220 }, + }, }} > @@ -545,7 +776,9 @@ export const MonitoringWellsReportView = () => { ))} {fieldState.error && ( - {fieldState.error.message} + + {fieldState.error.message} + )} )} @@ -558,53 +791,168 @@ export const MonitoringWellsReportView = () => { 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", + {series.length === 0 ? ( + + {wellIds.length === 0 ? ( + + Select at least one well to display data. + + ) : ( + No data to display. + )} + + ) : ( + { + 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%" }} - /> + }} + sx={{ width: "100%", height: "100%" }} + /> + )} - + { + navigate({ + to: "/reports/monitoringwells", + search: (prev) => ({ + ...(prev as any), + m_pageSize: m.pageSize, + m_page: + m.pageSize !== (prev as any).m_pageSize ? 0 : m.page, + }), + replace: true, + }); }} /> - - + + + Report Averages ({bucketLabel}) + + + + All selected monitoring wells average:{" "} + + {allWellsLatest?.avg_value != null + ? `${allWellsLatest.avg_value.toFixed(2)} ft` + : "—"} + + + + + {from?.format("MMM D, YYYY")} → {to?.format("MMM D, YYYY")} + + + {reportAveragesQuery.isLoading && ( + Loading averages… + )} + {reportAveragesQuery.isError && ( + + Failed to load averages: {reportAveragesQuery.error.message} + + )} + + {!reportAveragesQuery.isLoading && !reportAveragesQuery.isError && ( + + { + navigate({ + to: "/reports/monitoringwells", + search: (prev) => ({ + ...(prev as any), + a_pageSize: m.pageSize, + a_page: + m.pageSize !== (prev as any).a_pageSize ? 0 : m.page, + }), + replace: true, + }); + }} + /> + + )} + + + diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index d8b1afdf..caaff1c8 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo } from "react"; -import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; +import { useEffect, useMemo, useRef } from "react"; +import { useAuthHeader } from "react-auth-kit"; +import { BuildOutlined, PictureAsPdf } from "@mui/icons-material"; import { Autocomplete, Button, @@ -7,24 +8,27 @@ import { CardContent, FormControlLabel, Grid, - IconButton, Switch, TextField, Tooltip, } from "@mui/material"; -import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; +import { useNavigate } from "@tanstack/react-router"; 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"; +import dayjs, { Dayjs } from "dayjs"; + +import { API_URL } from "@/config"; +import { + ControlledDatepicker, + BackgroundBox, + CustomCardHeader, + ControlledSelect, + ReportBreadcrumbTitle, +} from "@/components"; +import { Route } from "@/routes/reports/partsused"; export interface MeterType { id: number; @@ -63,7 +67,7 @@ const schema = yup.object().shape({ .mixed() .nullable() .required("To date is required") - .test("is-after", "'To' date must be after 'From'", function(value) { + .test("is-after", "'To' date must be after 'From'", function (value) { const { from } = this.parent; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), @@ -87,29 +91,57 @@ const schema = yup.object().shape({ .array() .of(yup.number().required()) .min(1, "At least one Part is required"), - in_use: yup.bool().required() + in_use: yup.bool().required(), }); const defaultSchema = { - from: dayjs().startOf('month'), - to: dayjs().endOf('month'), + from: dayjs().startOf("month"), + to: dayjs().endOf("month"), part_types: [], parts: [], - in_use: true + in_use: true, }; export const PartsUsedReportView = () => { - const { control, reset, watch } = useForm({ + const navigate = useNavigate(); + const search = Route.useSearch(); + const hydratedRef = useRef(false); + + const defaultValues = useMemo( + () => ({ + from: dayjs(search.from, "YYYY-MM-DD"), + to: dayjs(search.to, "YYYY-MM-DD"), + part_types: [], + parts: [], + in_use: search.in_use, + }), + [search.from, search.to, search.in_use], + ); + + const { control, reset, watch, setValue } = useForm({ resolver: yupResolver(schema), - defaultValues: defaultSchema, + defaultValues, }); + useEffect(() => { + hydratedRef.current = false; + reset(defaultValues); + }, [defaultValues, reset]); + const from = watch("from"); const to = watch("to"); const selectedPartIds = watch("parts") ?? []; const partTypes = watch("part_types"); const inUse = watch("in_use"); + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/partsused", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + const authHeader = useAuthHeader(); const partsQuery = useQuery({ queryKey: ["Inventory", "report", "partslist", inUse], @@ -126,6 +158,63 @@ export const PartsUsedReportView = () => { cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours }); + const partTypeOptions = useMemo( + () => [ + ...new Map( + (partsQuery?.data ?? []) + .map((option: Part) => ({ + id: option.part_type_id, + type: option.part_type, + })) + .map((item) => [item.id, item]), + ).values(), + ], + [partsQuery.data], + ); + + useEffect(() => { + setValue("from", dayjs(search.from, "YYYY-MM-DD"), { + shouldDirty: false, + shouldValidate: true, + }); + setValue("to", dayjs(search.to, "YYYY-MM-DD"), { + shouldDirty: false, + shouldValidate: true, + }); + setValue("in_use", search.in_use, { + shouldDirty: false, + shouldValidate: true, + }); + }, [search.from, search.to, search.in_use, setValue]); + + useEffect(() => { + if (!partsQuery.data) return; + + const selected = partTypeOptions.filter((option) => + search.part_types.includes(option.id), + ); + setValue("part_types", selected, { + shouldDirty: false, + shouldValidate: true, + }); + + const availablePartIds = new Set(partsQuery.data.map((part) => part.id)); + const selectedParts = search.parts.filter((id) => availablePartIds.has(id)); + + setValue("parts", selectedParts, { + shouldDirty: false, + shouldValidate: true, + }); + + hydratedRef.current = true; + }, [ + partTypeOptions, + partsQuery.data, + search.part_types, + search.parts, + setValue, + ]); + const filteredParts = useMemo(() => { if (!partsQuery.data) return []; @@ -139,16 +228,71 @@ export const PartsUsedReportView = () => { return partsQuery.data; }, [partsQuery.data, partTypes]); + const groupedFilteredParts = useMemo(() => { + return [...filteredParts].sort((a, b) => { + const typeCompare = (a.part_type?.name ?? "").localeCompare( + b.part_type?.name ?? "", + ); + if (typeCompare !== 0) return typeCompare; + + return `${a.part_number} ${a.description}`.localeCompare( + `${b.part_number} ${b.description}`, + ); + }); + }, [filteredParts]); + useEffect(() => { - const currentParts = watch("parts") ?? []; + if (!hydratedRef.current) return; + const validIds = filteredParts.map((p) => p.id); - const stillValid = currentParts.filter((id) => validIds.includes(id)); + const stillValid = selectedPartIds.filter((id) => validIds.includes(id)); - if (currentParts.length !== stillValid.length) { - // Drop invalid part IDs - reset({ ...watch(), parts: stillValid }); + if (selectedPartIds.length !== stillValid.length) { + setValue("parts", stillValid, { + shouldDirty: false, + shouldValidate: true, + }); + setSearch((prev) => ({ + ...prev, + parts: stillValid, + page: 0, + })); } - }, [partTypes, filteredParts]); + }, [filteredParts, selectedPartIds, setSearch, setValue]); + + useEffect(() => { + if (!hydratedRef.current) return; + + const nextFrom = from?.format("YYYY-MM-DD"); + const nextTo = to?.format("YYYY-MM-DD"); + const nextPartTypes = (partTypes ?? []).map((partType: any) => partType.id); + + setSearch((prev) => { + const sameFrom = prev.from === nextFrom; + const sameTo = prev.to === nextTo; + const sameInUse = prev.in_use === inUse; + const samePartTypes = + prev.part_types.length === nextPartTypes.length && + prev.part_types.every((value, index) => value === nextPartTypes[index]); + const sameParts = + prev.parts.length === selectedPartIds.length && + prev.parts.every((value, index) => value === selectedPartIds[index]); + + if (sameFrom && sameTo && sameInUse && samePartTypes && sameParts) { + return prev; + } + + return { + ...prev, + from: nextFrom, + to: nextTo, + part_types: nextPartTypes, + parts: selectedPartIds, + in_use: inUse, + page: 0, + }; + }); + }, [from, to, partTypes, selectedPartIds, inUse]); const partsUsedQuery = useQuery({ queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], @@ -271,37 +415,11 @@ export const PartsUsedReportView = () => { return ( - + } + icon={BuildOutlined} + /> - - - - - - - - - - - - - - - - - - { format="YYYY MMMM DD" /> - + { name="part_types" multiple disabled={partsQuery.isFetching} - options={[ - ...new Map( - partsQuery?.data - ?.map((option: Part) => ({ - id: option.part_type_id, - type: option.part_type, - })) - .map((item) => [item.id, item]), // key by id - ).values(), - ]} + options={partTypeOptions} getOptionLabel={(option: any) => option.type.name} /> + + + + + + + { option.part_type?.name ?? "Other"} getOptionLabel={(option: Part) => `${option.part_number} ${option.description}` } @@ -400,7 +537,7 @@ export const PartsUsedReportView = () => { }} /> - + { columns={columns} disableColumnMenu hideFooterSelectedRowCount + pagination pageSizeOptions={[5, 10, 25]} - initialState={{ - pagination: { - paginationModel: { pageSize: 5, page: 0 }, - }, - }} + paginationModel={{ page: search.page, pageSize: search.pageSize }} + onPaginationModelChange={(model) => + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } /> - - + + diff --git a/frontend/src/views/Reports/WorkOrders/index.tsx b/frontend/src/views/Reports/WorkOrders/index.tsx deleted file mode 100644 index 0fca7099..00000000 --- a/frontend/src/views/Reports/WorkOrders/index.tsx +++ /dev/null @@ -1,166 +0,0 @@ -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 index a479c2e2..68c0476d 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -1,57 +1,39 @@ import { - Assessment, - Build, - MonitorHeart, - Plumbing, - Science, + AssessmentOutlined, + BuildOutlined, + ConstructionOutlined, + MonitorHeartOutlined, + ScienceOutlined, } 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"; +import { BackgroundBox, CustomCardHeader, NavLink } from "@/components"; export const ReportsView = () => { return ( - + - {/* - */} - {/* - - */} - diff --git a/frontend/src/views/RouteErrorView.tsx b/frontend/src/views/RouteErrorView.tsx new file mode 100644 index 00000000..62189b4a --- /dev/null +++ b/frontend/src/views/RouteErrorView.tsx @@ -0,0 +1,176 @@ +import { useMemo } from "react"; +import { + Box, + Button, + Card, + CardContent, + Stack, + Typography, +} from "@mui/material"; +import { ContentCopy, Home, Refresh, Warning } from "@mui/icons-material"; +import { Link } from "@tanstack/react-router"; +import { useSnackbar } from "notistack"; +import { BackgroundBox, CustomCardHeader } from "@/components"; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + if (error && typeof error === "object") { + try { + return JSON.stringify(error, null, 2); + } catch { + return "An unknown routing error occurred."; + } + } + + return "An unknown routing error occurred."; +}; + +const getExactUrl = (): string => { + if (typeof window === "undefined") { + return "Unavailable during server rendering"; + } + + return window.location.href; +}; + +type RouteErrorViewProps = { + error: unknown; + onRetry?: () => void; +}; + +export const RouteErrorView = ({ error, onRetry }: RouteErrorViewProps) => { + const { enqueueSnackbar } = useSnackbar(); + const errorMessage = useMemo(() => getErrorMessage(error), [error]); + const exactUrl = useMemo(() => getExactUrl(), []); + + const copyValue = async (label: string, value: string) => { + try { + await navigator.clipboard.writeText(value); + enqueueSnackbar(`${label} copied.`, { variant: "success" }); + } catch { + enqueueSnackbar(`Unable to copy ${label.toLowerCase()}.`, { + variant: "error", + }); + } + }; + + return ( + + + + + + + + + We encountered an unexpected error while loading this page. + + + + To help us resolve this as quickly as possible, please copy + the Error Message and the{" "} + Exact URL below and include them when + reporting this issue to support. + + + + + + + Error Message + + + {errorMessage} + + + + + + + Exact URL + + + {exactUrl} + + + + + + {onRetry && ( + + )} + + + + + + ); +}; diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index e56aeaf0..f03bfdbe 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -1,4 +1,5 @@ -import * as yup from 'yup'; +import { useEffect, useState } from "react"; +import * as yup from "yup"; import { enqueueSnackbar } from "notistack"; import { yupResolver } from "@hookform/resolvers/yup"; import { useForm, Controller } from "react-hook-form"; @@ -21,26 +22,29 @@ import { Skeleton, IconButton, Stack, + InputAdornment, } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; import { useAuthUser, useSignIn } from "react-auth-kit"; +import { Check, Close, Delete, Edit, ExpandMore } from "@mui/icons-material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { - Check, - Close, - Edit, - ExpandMore -} from '@mui/icons-material'; -import { BackgroundBox, CustomCardHeader, ImageUploadWithPreview, IsTrueChip, RoleChip } from "../components"; -import { navConfig } from '../constants'; -import { useFetchWithAuth } from '../hooks'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { SecurityScope } from '../interfaces'; -import { useEffect, useState } from 'react'; + BackgroundBox, + CustomCardHeader, + ImageUploadWithPreview, + IsTrueChip, + RoleChip, +} from "@/components"; +import { navConfig } from "@/constants"; +import { useFetchWithAuth } from "@/hooks"; +import { SecurityScope } from "@/interfaces"; +import { clearSavedQueryLocalStorage } from "@/service"; const redirectOptions = { - public: navConfig.filter(item => !item.role), - technician: navConfig.filter(item => item.role === "Technician"), - admin: navConfig.filter(item => item.role === "Admin"), + public: navConfig.filter((item) => !item.role), + technician: navConfig.filter((item) => item.role === "Technician"), + admin: navConfig.filter((item) => item.role === "Admin"), }; const redirectSchema = yup.object().shape({ @@ -49,7 +53,10 @@ const redirectSchema = yup.object().shape({ const passwordSchema = yup.object().shape({ currentPassword: yup.string().required("Current password is required"), - newPassword: yup.string().required("New password is required"), + newPassword: yup + .string() + .min(8, "New password must be at least 8 characters") + .required("New password is required"), confirmPassword: yup .string() .oneOf([yup.ref("newPassword")], "Passwords must match") @@ -63,14 +70,20 @@ export const Settings = () => { const fetchWithAuth = useFetchWithAuth(); const scopes: Set = new Set( authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string - ) ?? [] + (scope: SecurityScope) => scope.scope_string, + ) ?? [], ); const hasReadScope = scopes.has("read"); const hasAdminScope = scopes.has("admin"); const [isEditing, setIsEditing] = useState(false); + const [avatarFiles, setAvatarFiles] = useState([]); + const [avatarUploadKey, setAvatarUploadKey] = useState(0); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isClearingCachedData, setIsClearingCachedData] = useState(false); const { control: displayNameControl, @@ -89,17 +102,19 @@ export const Settings = () => { }); }, onSuccess: (responseJson: any) => { - enqueueSnackbar("Display name updated successfully.", { variant: "success" }); + enqueueSnackbar("Display name updated successfully.", { + variant: "success", + }); // Grab the current auth state & update it if (user) { signIn({ token: localStorage.getItem("_auth")!, // reuse current token - expiresIn: 300, // reuse the expiry window you want + expiresIn: 300, // reuse the expiry window you want tokenType: "bearer", authState: { ...user, - display_name: responseJson.display_name, // overwrite just this field + display_name: responseJson.display_name, // overwrite just this field }, }); } @@ -110,16 +125,17 @@ export const Settings = () => { }); const onDisplayNameSubmit = ({ display_name }: { display_name: string }) => { - displayNameMutation.mutate({ display_name }) - } + displayNameMutation.mutate({ display_name }); + }; const queryClient = useQueryClient(); const getRedirectPageQuery = useQuery({ queryKey: ["redirectPage"], - queryFn: async () => fetchWithAuth({ - method: "GET", - route: "/settings/redirect_page", - }), + queryFn: async () => + fetchWithAuth({ + method: "GET", + route: "/settings/redirect_page", + }), }); const redirectMutation = useMutation({ @@ -130,19 +146,21 @@ export const Settings = () => { body: data, }); }, - onSuccess: (responseJson: { message: string, redirect_page: string }) => { - enqueueSnackbar("Redirect page updated successfully.", { variant: "success" }); + onSuccess: (responseJson: { message: string; redirect_page: string }) => { + enqueueSnackbar("Redirect page updated successfully.", { + variant: "success", + }); queryClient.invalidateQueries(["redirectPage"]); // Grab the current auth state & update it if (user) { signIn({ token: localStorage.getItem("_auth")!, // reuse current token - expiresIn: 300, // reuse the expiry window you want + expiresIn: 300, // reuse the expiry window you want tokenType: "bearer", authState: { ...user, - redirect_page: responseJson.redirect_page, // overwrite just this field + redirect_page: responseJson.redirect_page, // overwrite just this field }, }); } @@ -155,10 +173,12 @@ export const Settings = () => { const { control: redirectControl, handleSubmit: handleRedirectSubmit, - reset: redirectReset + reset: redirectReset, } = useForm({ resolver: yupResolver(redirectSchema), - defaultValues: { redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/" }, + defaultValues: { + redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/", + }, values: { redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/" }, // react-hook-form v7 pattern for sync }); @@ -177,25 +197,39 @@ export const Settings = () => { currentPassword: string; newPassword: string; }) => { - const res = await fetch("/settings/password_reset", { + return await fetchWithAuth({ method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("_auth")}`, + route: "/settings/password_reset", + body: { + current_password: data.currentPassword, + new_password: data.newPassword, }, - body: JSON.stringify(data), }); - if (!res.ok) throw new Error("Password reset failed"); - return await res.json(); }, onSuccess: () => { - enqueueSnackbar("Password reset request submitted.", { variant: "success" }); + enqueueSnackbar("Password updated successfully.", { + variant: "success", + }); + passwordReset({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + setShowCurrentPassword(false); + setShowNewPassword(false); + setShowConfirmPassword(false); + }, + onError: (error: Error) => { + enqueueSnackbar(error.message || "Failed to update password.", { + variant: "error", + }); }, }); const { control: passwordControl, handleSubmit: handlePasswordSubmit, + reset: passwordReset, formState: { errors: passwordErrors }, } = useForm({ resolver: yupResolver(passwordSchema), @@ -213,6 +247,102 @@ export const Settings = () => { }); }; + const avatarMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("avatar", file); + + return await fetchWithAuth({ + method: "POST", + route: "/settings/avatar", + body: formData, + }); + }, + onSuccess: (responseJson: { avatar_img: string }) => { + enqueueSnackbar("Avatar updated successfully.", { + variant: "success", + }); + setAvatarFiles([]); + setAvatarUploadKey((current) => current + 1); + + if (user) { + signIn({ + token: localStorage.getItem("_auth")!, + expiresIn: 300, + tokenType: "bearer", + authState: { + ...user, + avatar_img: responseJson.avatar_img, + }, + }); + } + }, + onError: () => { + enqueueSnackbar("Failed to update avatar.", { variant: "error" }); + }, + }); + + const clearAvatarMutation = useMutation({ + mutationFn: async () => { + return await fetchWithAuth({ + method: "DELETE", + route: "/settings/avatar", + }); + }, + onSuccess: () => { + enqueueSnackbar("Avatar removed successfully.", { + variant: "success", + }); + setAvatarFiles([]); + setAvatarUploadKey((current) => current + 1); + + if (user) { + signIn({ + token: localStorage.getItem("_auth")!, + expiresIn: 300, + tokenType: "bearer", + authState: { + ...user, + avatar_img: null, + }, + }); + } + }, + onError: () => { + enqueueSnackbar("Failed to remove avatar.", { variant: "error" }); + }, + }); + + const onAvatarSubmit = () => { + const file = avatarFiles[0]; + if (!file) { + enqueueSnackbar("Select an image before saving your avatar.", { + variant: "warning", + }); + return; + } + + avatarMutation.mutate(file); + }; + + const handleClearCachedData = () => { + setIsClearingCachedData(true); + + try { + clearSavedQueryLocalStorage(); + queryClient.clear(); + enqueueSnackbar("Saved cache cleared.", { + variant: "success", + }); + } catch { + enqueueSnackbar("Failed to clear cached data.", { + variant: "error", + }); + } finally { + setIsClearingCachedData(false); + } + }; + return ( @@ -225,19 +355,55 @@ export const Settings = () => { - + Full Name: - + - + Email: - + - + Username: - + - + {!isEditing ? ( <> Display Name: @@ -246,7 +412,10 @@ export const Settings = () => { label={user?.display_name ?? "N/A"} variant="outlined" /> - setIsEditing(true)}> + setIsEditing(true)} + > @@ -269,24 +438,41 @@ export const Settings = () => { { - displayNameReset({ display_name: user?.display_name ?? "" }); + displayNameReset({ + display_name: user?.display_name ?? "", + }); setIsEditing(false); }} > - + )} - + Role: - + Active: @@ -298,21 +484,60 @@ export const Settings = () => { - + }> Avatar Configuration - - - + + + + + + + + + }> - Redirect Page After Login + + Redirect Page After Login + @@ -326,13 +551,22 @@ export const Settings = () => { render={({ field }) => { // flatten all available paths const availablePaths = [ - ...redirectOptions.public.map(o => o.path), - ...(hasReadScope ? redirectOptions.technician.map(o => o.path) : []), - ...(hasAdminScope ? redirectOptions.admin.map(o => o.path) : []), + ...redirectOptions.public.map((o) => o.path), + ...(hasReadScope + ? redirectOptions.technician.map( + (o) => o.path, + ) + : []), + ...(hasAdminScope + ? redirectOptions.admin.map((o) => o.path) + : []), ]; // guard: if no options available yet, render empty select - if (getRedirectPageQuery.isFetching && availablePaths.length === 0) { + if ( + getRedirectPageQuery.isFetching && + availablePaths.length === 0 + ) { return ( { ); } - const safeValue = availablePaths.includes(field.value) + const safeValue = availablePaths.includes( + field.value, + ) ? field.value : "/"; @@ -352,66 +588,122 @@ export const Settings = () => { {...field} select fullWidth - size='small' + size="small" label="Page to redirect after login" - disabled={getRedirectPageQuery?.isFetching || redirectMutation.isLoading} + disabled={ + getRedirectPageQuery?.isFetching || + redirectMutation.isLoading + } value={safeValue} onChange={(e) => field.onChange(e)} > {redirectOptions.public.length > 0 && [ - + Pages , - ...redirectOptions.public.map((option) => { - const Icon = option.icon; - return ( - - - - - - {option.label} - - - ); - }), - ]} - {hasReadScope && redirectOptions.technician.length > 0 && [ - - Pages - , - ...redirectOptions.technician.map((option) => { - const Icon = option.icon; - return ( - - - - - - {option.label}{option.parent === "reports" ? " Report" : null} - - - ); - }), - ]} - {hasAdminScope && redirectOptions.admin.length > 0 && [ - - Pages - , - ...redirectOptions.admin.map((option) => { - const Icon = option.icon; - return ( - - - - - - {option.label} - - - ); - }), + ...redirectOptions.public.map( + (option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + + + ); + }, + ), ]} + {hasReadScope && + redirectOptions.technician.length > 0 && [ + + Pages + , + ...redirectOptions.technician.map( + (option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + {option.parent === "reports" + ? " Report" + : null} + + + ); + }, + ), + ]} + {hasAdminScope && + redirectOptions.admin.length > 0 && [ + + Pages + , + ...redirectOptions.admin.map( + (option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + + + ); + }, + ), + ]} ); }} @@ -428,7 +720,7 @@ export const Settings = () => { - + }> Password Reset @@ -444,12 +736,37 @@ export const Settings = () => { render={({ field }) => ( + + setShowCurrentPassword( + (show) => !show, + ) + } + > + {showCurrentPassword ? ( + + ) : ( + + )} + + + ), + }} /> )} /> @@ -461,12 +778,33 @@ export const Settings = () => { render={({ field }) => ( + + setShowNewPassword((show) => !show) + } + > + {showNewPassword ? ( + + ) : ( + + )} + + + ), + }} /> )} /> @@ -478,12 +816,37 @@ export const Settings = () => { render={({ field }) => ( + + setShowConfirmPassword( + (show) => !show, + ) + } + > + {showConfirmPassword ? ( + + ) : ( + + )} + + + ), + }} /> )} /> @@ -505,9 +868,39 @@ export const Settings = () => { + + Cached Data + + + + + }> + Clear Saved Cache + + + + + Clears stored app data so pages load fresh information the + next time you open them. + + + + + + + + + - + ); }; - diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 0f6a66a8..1fc5c553 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -1,89 +1,36 @@ -import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Button, Card, CardContent, Grid, InputAdornment, TextField, Tooltip } from "@mui/material"; -import { useGetSecurityScopes } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -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"; +import { Card, CardContent, Grid } from "@mui/material"; +import { VerifiedUserOutlined } from "@mui/icons-material"; +import { useGetSecurityScopes } from "@/service"; +import { CustomCardHeader } from "@/components"; export const PermissionsTable = () => { const securityScopesList = useGetSecurityScopes(); - const [permissionSearchQuery, setPermissionSearchQuery] = - useState(""); - const [filteredRows, setFilteredRows] = useState(); const cols: GridColDef[] = [ - { field: "scope_string", headerName: "Permission Name", width: 200 }, - { field: "description", headerName: "Desciption", width: 600 }, + { + field: "scope_string", + headerName: "Permission Name", + flex: 1, + }, + { field: "description", headerName: "Desciption", flex: 3 }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid - useEffect(() => { - const psq = permissionSearchQuery.toLowerCase(); - let filtered = (securityScopesList.data ?? []).filter( - (row) => - row.scope_string.toLowerCase().includes(psq) || - row.description.toLowerCase().includes(psq), - ); - - setFilteredRows(filtered); - }, [permissionSearchQuery, securityScopesList.data]); - return ( - + - - setPermissionSearchQuery(event.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - - - - - ), - }, - }} disableColumnFilter + disableRowSelectionOnClick + hideFooter /> diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 8fea20dd..958cff13 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -14,11 +14,7 @@ import { OutlinedInput, Select, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; -import CancelIcon from "@mui/icons-material/Cancel"; +import { Add, Edit, Save, SaveAs, Cancel } from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; @@ -26,24 +22,24 @@ import { useFieldArray } from "react-hook-form"; import { useCreateRole, + useGetRoles, useGetSecurityScopes, useUpdateRole, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import { SecurityScope, UserRole } from "../../interfaces"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +} from "@/service"; +import { ControlledTextbox, CustomCardHeader } from "@/components"; +import { SecurityScope, UserRole } from "@/interfaces"; const RoleResolverSchema: Yup.ObjectSchema = Yup.object().shape({ name: Yup.string().required("Please enter a name."), }); interface RoleDetailsCardProps { - selectedRole: UserRole | undefined; + roleId?: number; roleAddMode: boolean; } export const RoleDetailsCard = ({ - selectedRole, + roleId, roleAddMode, }: RoleDetailsCardProps) => { const { @@ -63,6 +59,9 @@ export const RoleDetailsCard = ({ }); const securityScopeList = useGetSecurityScopes(); + const rolesList = useGetRoles(); + + const selectedRole = rolesList.data?.find((role) => role.id === roleId); function onSuccessfulUpdate() { enqueueSnackbar("Successfully Updated Role!", { variant: "success" }); @@ -80,18 +79,23 @@ export const RoleDetailsCard = ({ // Populate the form with the selected role's details useEffect(() => { + if (roleAddMode) { + reset(); + return; + } + if (selectedRole != undefined) { reset(); Object.entries(selectedRole).forEach(([field, value]) => { setValue(field as any, value); }); + return; } - }, [selectedRole]); - // Empty the form if entering role add mode - useEffect(() => { - if (roleAddMode) reset(); - }, [roleAddMode]); + if (roleId == undefined) { + reset(); + } + }, [roleAddMode, roleId, reset, selectedRole, setValue]); function removeSecurityScope(securityScopeIndex: number) { remove(securityScopeIndex); @@ -113,7 +117,7 @@ export const RoleDetailsCard = ({ @@ -148,7 +152,7 @@ export const RoleDetailsCard = ({ label={value.scope_string} clickable deleteIcon={ - event.stopPropagation() } @@ -189,7 +193,7 @@ export const RoleDetailsCard = ({ variant="contained" onClick={handleSubmit(onAddPart, onErr)} > - +   Save New Role ) : ( @@ -198,7 +202,7 @@ export const RoleDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 6ccf9994..194f7db7 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, @@ -9,31 +9,45 @@ import { 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 { UserRole } from "../../interfaces"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { Search, Add, AdminPanelSettingsOutlined } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { useGetRoles } from "@/service"; +import { Route } from "@/routes/manage/users"; +import { CustomCardHeader, GridFooterWithButton } from "@/components"; export const RolesTable = ({ - setSelectedRole, - setRoleAddMode, + onSelectRole, + onCreateRole, }: { - setSelectedRole: Function; - setRoleAddMode: Function; + onSelectRole: (id: number) => void; + onCreateRole: () => void; }) => { const rolesList = useGetRoles(); - const [roleSearchQuery, setRoleSearchQuery] = useState(""); - const [filteredRows, setFilteredRows] = useState(); + const navigate = useNavigate(); + const search = Route.useSearch(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/users", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + const filteredRows = useMemo(() => { + const q = (search.role_q ?? "").toLowerCase(); + + return (rolesList.data ?? []).filter((row) => + row.name.toLowerCase().includes(q), + ); + }, [rolesList.data, search.role_q]); const cols: GridColDef[] = [ - { field: "name", headerName: "Role Name", width: 200 }, + { field: "name", headerName: "Role Name", flex: 1 }, { field: "security_scopes", headerName: "Permissions", - width: 600, + flex: 3, renderCell: (params: any) => { const maxChips = 5; const additional = params?.value.length - maxChips; @@ -55,32 +69,33 @@ export const RolesTable = ({ }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid - useEffect(() => { - const psq = roleSearchQuery.toLowerCase(); - let filtered = (rolesList.data ?? []).filter((row) => - row.name.toLowerCase().includes(psq), - ); - - setFilteredRows(filtered); - }, [roleSearchQuery, rolesList.data]); - return ( - + - + setRoleSearchQuery(event.target.value)} + value={search.role_q ?? ""} + onChange={(event: any) => + setSearch((prev) => ({ + ...prev, + role_q: event.target.value, + r_page: 0, + })) + } InputProps={{ startAdornment: ( @@ -94,15 +109,30 @@ export const RolesTable = ({ + setSearch((prev) => ({ + ...prev, + r_pageSize: model.pageSize, + r_page: model.pageSize !== prev.r_pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50, 100]} + rowSelectionModel={search.role_id ? [search.role_id] : []} loading={rolesList.isLoading} columns={cols} disableColumnMenu onRowClick={(selectedRow) => { - setSelectedRole( - rolesList.data?.find( - (role: UserRole) => role.id == selectedRow.row.id, - ), - ); + if (search.role_id === selectedRow.row.id) { + onCreateRole(); + return; + } + + onSelectRole(selectedRow.row.id); }} slots={{ footer: GridFooterWithButton }} slotProps={{ @@ -111,10 +141,10 @@ export const RolesTable = ({ ), diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 1bcf638d..b2a85176 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -11,12 +11,14 @@ import { Grid, Typography, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; -import LockResetIcon from "@mui/icons-material/LockReset"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { + Add, + Edit, + Save, + SaveAs, + LockReset, + ExpandMore, +} from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; @@ -26,14 +28,15 @@ import { useUpdateUser, useGetRoles, useUpdateUserPassword, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import { UpdatedUserPassword, User, UserRole } from "../../interfaces"; + useGetUser, +} from "@/service"; import { + ControlledTextbox, ControlledSelect, ControlledSelectNonObject, -} from "../../components/RHControlled/ControlledSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; + CustomCardHeader, +} from "@/components"; +import { UpdatedUserPassword, User, UserRole } from "@/interfaces"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ full_name: Yup.string().required("Please enter a full name."), @@ -50,21 +53,20 @@ const formatSubmission = (user: User) => { formattedUser.user_role_id = user.user_role?.id; delete formattedUser.user_role; return formattedUser; -} +}; const SetNewPasswordAccordion = ({ control, errorMessage, - handleSubmit + handleSubmit, }: any) => { return ( } + expandIcon={} sx={{ m: 0, mx: 2, p: 0, color: "#595959" }} > - {" "} -   +   Set New Password for User @@ -81,7 +83,7 @@ const SetNewPasswordAccordion = ({ @@ -89,15 +91,25 @@ const SetNewPasswordAccordion = ({ ); -} +}; export const UserDetailsCard = ({ - selectedUser, + userId, userAddMode, }: { - selectedUser?: User; + userId?: number; userAddMode: boolean; }) => { + const userQuery = useGetUser(userId!, { enabled: !!userId && !userAddMode }); + + useEffect(() => { + if (!userAddMode && userQuery.data) { + reset(); + Object.entries(userQuery.data).forEach(([k, v]) => setValue(k as any, v)); + } + if (userAddMode) reset(); + }, [userAddMode, userQuery.data]); + const rolesList = useGetRoles(); const { handleSubmit, @@ -113,11 +125,13 @@ export const UserDetailsCard = ({ const onSuccessfulUpdate = () => enqueueSnackbar("Successfully Updated User!", { variant: "success" }); const onSuccessfulPasswordUpdate = () => - enqueueSnackbar("Successfully Updated User's Password!", { variant: "success" }); + enqueueSnackbar("Successfully Updated User's Password!", { + variant: "success", + }); const onSuccessfulCreate = () => { enqueueSnackbar("Successfully Created New User!", { variant: "success" }); reset(); - } + }; const onErr = (data: any) => console.error("ERR: ", data); @@ -125,7 +139,8 @@ export const UserDetailsCard = ({ const createUser = useCreateUser(onSuccessfulCreate); const updateUserPassword = useUpdateUserPassword(onSuccessfulPasswordUpdate); - const onSaveChanges = (user: User) => updateUser.mutate(formatSubmission(user)); + const onSaveChanges = (user: User) => + updateUser.mutate(formatSubmission(user)); const onCreateUser = (user: User) => { if (!user.password || user.password.length < 1) { @@ -133,7 +148,7 @@ export const UserDetailsCard = ({ return; } createUser.mutate(formatSubmission(user)); - } + }; const onUpdateUserPassword = ( userId: number, @@ -148,16 +163,7 @@ export const UserDetailsCard = ({ new_password: newPassword, }; updateUserPassword.mutate(updatedUserPassword); - } - - useEffect(() => { - if (selectedUser != undefined) { - reset(); - Object.entries(selectedUser).forEach(([field, value]) => { - setValue(field as any, value); - }); - } - }, [selectedUser]); + }; useEffect(() => { if (userAddMode) reset(); @@ -169,7 +175,7 @@ export const UserDetailsCard = ({ @@ -262,7 +268,7 @@ export const UserDetailsCard = ({ variant="contained" onClick={handleSubmit(onCreateUser, onErr)} > - +   Save New User ) : ( @@ -271,7 +277,7 @@ export const UserDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index e3a38a6b..02b6fe80 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -1,58 +1,77 @@ import { Grid } from "@mui/material"; -import { useEffect, useState } from "react"; -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 { BackgroundBox } from "../../components/BackgroundBox"; +import { BackgroundBox } from "@/components"; +import { Route } from "@/routes/manage/users"; +import { useNavigate } from "@tanstack/react-router"; -export const UserManagementView = () => { - const [selectedUser, setSelectedUser] = useState(); - const [userAddMode, setUserAddMode] = useState(true); - const [selectedRole, setSelectedRole] = useState(); - const [roleAddMode, setRoleAddMode] = useState(true); +import { UsersTable } from "@/views/UserManagement/UsersTable"; +import { UserDetailsCard } from "@/views/UserManagement/UserDetailsCard"; +import { RolesTable } from "@/views/UserManagement/RolesTable"; +import { RoleDetailsCard } from "@/views/UserManagement/RoleDetailsCard"; +import { PermissionsTable } from "@/views/UserManagement/PermissionsTable"; - useEffect(() => { - if (selectedUser) setUserAddMode(false); - }, [selectedUser]); +export const UserManagementView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); - useEffect(() => { - if (selectedRole) setRoleAddMode(false); - }, [selectedRole]); + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/users", + search: (prev) => updater(prev as any), + replace: true, + }); + }; return ( + setSearch((prev) => ({ + ...prev, + user_id: id, + user_add: false, + })) + } + onCreateUser={() => + setSearch((prev) => ({ + ...prev, + user_id: undefined, + user_add: true, + })) + } /> + setSearch((prev) => ({ ...prev, role_id: id, role_add: false })) + } + onCreateRole={() => + setSearch((prev) => ({ + ...prev, + role_id: undefined, + role_add: true, + })) + } /> - + ); }; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 48f901ec..a6f564a4 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { + Box, Button, Card, CardContent, @@ -9,36 +10,98 @@ import { 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 { User } from "../../interfaces"; -import TristateToggle from "../../components/TristateToggle"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { RoleChip, CustomCardHeader, IsTrueChip } from "../../components"; +import { Search, Add, People } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/users"; +import { useGetUserAdminList } from "@/service"; +import { + CustomCardHeader, + GridFooterWithButton, + IsTrueChip, + ManageBreadcrumbTitle, + RoleChip, + TristateToggle, + UserAvatar, +} from "@/components"; export const UsersTable = ({ - setSelectedUser, - setUserAddMode, + onSelectUser, + onCreateUser, }: { - setSelectedUser: Function; - setUserAddMode: Function; + onSelectUser: (id: number) => void; + onCreateUser: () => void; }) => { const usersList = useGetUserAdminList(); - const [userSearchQuery, setUserSearchQuery] = useState(""); - const [filteredRows, setFilteredRows] = useState(); - const [isActiveFilter, setIsActiveFilter] = useState(); - const [isTechnicianFilter, setIsTechnicianFilter] = useState(); + const navigate = useNavigate(); + const search = Route.useSearch(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/users", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + const filteredRows = useMemo(() => { + const q = (search.user_q ?? "").toLowerCase(); + + let rows = (usersList.data ?? []).filter( + (row) => + row.full_name.toLowerCase().includes(q) || + row.email?.toLowerCase().includes(q) || + row.username?.toLowerCase().includes(q), + ); + + if (search.active !== "all") { + const wantActive = search.active === "true"; + rows = rows.filter((row) => !row.disabled === wantActive); + } + + if (search.tech !== "all") { + const wantTech = search.tech === "true"; + rows = rows.filter( + (row) => (row.user_role?.name === "Technician") === wantTech, + ); + } + + return rows; + }, [usersList.data, search.user_q, search.active, search.tech]); const cols: GridColDef[] = [ + { + field: "avatar_img", + headerName: "Avatar", + width: 70, + sortable: false, + filterable: false, + renderCell: (params: any) => ( + + + + ), + }, { field: "full_name", headerName: "Full Name", width: 200 }, { field: "user_role", headerName: "Role", width: 125, valueGetter: (_, row) => row.user_role.name, - renderCell: (params: any) => + renderCell: (params: any) => , }, { field: "email", headerName: "Email", width: 250 }, { field: "username", headerName: "Username", width: 150 }, @@ -46,46 +109,43 @@ export const UsersTable = ({ field: "disabled", headerName: "Active", width: 80, - renderCell: (params: any) => + renderCell: (params: any) => , }, { field: "display_name", headerName: "Display Name", width: 150 }, { field: "redirect_page", headerName: "Redirect Page", width: 200 }, ]; - useEffect(() => { - const psq = userSearchQuery.toLowerCase(); - let filtered = (usersList.data ?? []).filter( - (row) => - row.full_name.toLowerCase().includes(psq) || - row.email?.toLowerCase().includes(psq) || - row.username?.toLowerCase().includes(psq), - ); - if (isActiveFilter != undefined) - filtered = filtered.filter((row) => !row.disabled == isActiveFilter); - if (isTechnicianFilter != undefined) - filtered = filtered.filter( - (row) => (row?.user_role?.name == "Technician") == isTechnicianFilter, - ); - - setFilteredRows(filtered); - }, [userSearchQuery, usersList.data, isActiveFilter, isTechnicianFilter]); - return ( } + icon={People} /> - + setUserSearchQuery(event.target.value)} + value={search.user_q ?? ""} + onChange={(e) => + setSearch((prev) => ({ + ...prev, + user_q: e.target.value, + u_page: 0, + })) + } InputProps={{ startAdornment: ( @@ -95,18 +155,39 @@ export const UsersTable = ({ }} /> - - Choose Filters: + + + Choose Filters:{" "} + - setIsActiveFilter(state) + value={search.active} + onToggle={(next) => + setSearch((prev) => ({ + ...prev, + active: next, + u_page: 0, + })) } /> - setIsTechnicianFilter(state) + value={search.tech} + onToggle={(next) => + setSearch((prev) => ({ + ...prev, + tech: next, + u_page: 0, + })) } /> @@ -115,15 +196,30 @@ export const UsersTable = ({ + setSearch((prev) => ({ + ...prev, + u_pageSize: model.pageSize, + u_page: model.pageSize !== prev.u_pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50, 100]} + rowSelectionModel={search.user_id ? [search.user_id] : []} loading={usersList.isLoading} columns={cols} disableColumnMenu - onRowClick={(selectedRow) => { - setSelectedUser( - usersList.data?.find( - (user: User) => user.id == selectedRow.row.id, - ), - ); + onRowClick={(r) => { + if (search.user_id === r.row.id) { + onCreateUser(); + return; + } + + onSelectUser(r.row.id); }} slots={{ footer: GridFooterWithButton }} slotProps={{ @@ -132,10 +228,9 @@ export const UsersTable = ({ ), @@ -145,6 +240,6 @@ export const UsersTable = ({ /> - + ); }; diff --git a/frontend/src/views/UserManagement/index.ts b/frontend/src/views/UserManagement/index.ts new file mode 100644 index 00000000..8350cab9 --- /dev/null +++ b/frontend/src/views/UserManagement/index.ts @@ -0,0 +1 @@ +export * from './UserManagementView' diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index 6175a3b5..ced10aa0 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useQueryClient } from "react-query"; import { @@ -10,11 +10,10 @@ import { FormControlLabel, Grid, Stack, + Typography, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; +import { Add, Edit, Save, SaveAs } from "@mui/icons-material"; +import { useAuthUser } from "react-auth-kit"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; @@ -25,8 +24,7 @@ import { useGetWaterSources, useGetWellStatusTypes, useUpdateWell, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; +} from "@/service"; import { SubmitWellCreate, WellUpdate, @@ -34,15 +32,17 @@ import { Well, WellStatus, WellUseLU, -} from "../../interfaces"; -import { ControlledSelect } from "../../components/RHControlled/ControlledSelect"; -import ControlledDMS from "../../components/RHControlled/ControlledDMS"; -import { GCSdimension } from "../../enums"; -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"; + SecurityScope, +} from "@/interfaces"; +import { + ControlledTextbox, + ControlledSelect, + ControlledDMS, + MergeWellModal, + ControlledCheckbox, + CustomCardHeader, +} from "@/components"; +import { GCSdimension } from "@/enums"; const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ use_type: Yup.object().required("Please select a use type."), @@ -124,7 +124,7 @@ export const WellDetailsCard = ({ const hasErrors = () => Object.keys(errors).length > 0; // Modal related functions - const [isWellMergeModalOpen, setIsWellMergeModalOpen] = React.useState(false); + const [isWellMergeModalOpen, setIsWellMergeModalOpen] = useState(false); const handleOpenMergeModal = () => setIsWellMergeModalOpen(true); const handleCloseMergeModal = () => setIsWellMergeModalOpen(false); @@ -132,7 +132,7 @@ export const WellDetailsCard = ({ @@ -270,11 +270,18 @@ export const WellDetailsCard = ({ -

Well Location -

+
- +   Save New Well ) : ( @@ -324,7 +331,7 @@ export const WellDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index 9927a275..dbb4df96 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -1,37 +1,28 @@ import { Grid } from "@mui/material"; -import { useEffect, useState } from "react"; -import { WellsTable } from "./WellsTable"; -import { Well } from "../../interfaces"; -import { WellDetailsCard } from "./WellDetailsCard"; -import { BackgroundBox } from "../../components/BackgroundBox"; +import { Route } from "@/routes/manage/wells"; +import { BackgroundBox } from "@/components"; -export default function WellManagementView() { - const [wellAddMode, setWellAddMode] = useState(true); - const [selectedWell, setSelectedWell] = useState(); +import { WellDetailsCard } from "@/views/WellManagement/WellDetailsCard"; +import { WellsTable } from "@/views/WellManagement/WellsTable"; +import { useGetWellById } from "@/service"; - useEffect(() => { - if (selectedWell) setWellAddMode(false); - }, [selectedWell]); +export const WellManagementView = () => { + const search = Route.useSearch(); + const selectedWellQuery = useGetWellById(search.well_id); return ( - + - + ); -} +}; diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index c622f495..541f6afe 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -1,12 +1,23 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useDebounce } from "use-debounce"; import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; import { Box, Typography } from "@mui/material"; -import { useGetWellLocations } from "../../service/ApiServiceNew"; -import { Well } from "../../interfaces"; -import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, WellMapLegend } from "../../components"; -import { BlueMapIcon, RedMapIcon, BlackMapIcon } from "../../components/MapIcons"; -import { WellStatus } from "../../enums"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/wells"; +import { useGetWellLocations } from "@/service"; +import { Well } from "@/interfaces"; +import { + BoundariesLayer, + MapUrlStateSync, + OpenStreetMapLayer, + SatelliteLayer, + SoutheastGuideLayer, + MapFullscreenToggle, + TransporationLayer, + WellMapLegend, +} from "@/components"; +import { BlueMapIcon, RedMapIcon, BlackMapIcon } from "@/components/MapIcons"; +import { WellStatus } from "@/enums"; import L from "leaflet"; import "leaflet/dist/leaflet.css"; @@ -15,15 +26,58 @@ import "leaflet/dist/leaflet.css"; import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + getMapLayersControlKey, + normalizeMapBaseLayer, + normalizeMapOverlayNames, + parseMapView, +} from "@/utils"; + +const BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; +const OVERLAY_NAMES = [ + "Wells", + "Clorides Report Region Guide", + "Transportation", + "Boundaries and Places", +] as const; +const DEFAULT_BASE_LAYER = "OpenStreetMap"; +const DEFAULT_OVERLAYS = ["Clorides Report Region Guide", "Wells"]; + export default function WellSelectionMap({ - setSelectedWell, wellSearchQueryProp, }: { wellSearchQueryProp: string; - setSelectedWell: Function; }) { + const navigate = useNavigate(); + const search = Route.useSearch(); + const mapContainerRef = useRef(null); + const [wellSearchDebounced] = useDebounce(wellSearchQueryProp, 250); const wellQuery = useGetWellLocations(wellSearchDebounced); + const mapBaseLayer = normalizeMapBaseLayer( + search.mapBase, + BASE_LAYER_NAMES, + DEFAULT_BASE_LAYER, + ); + const mapOverlayNames = normalizeMapOverlayNames( + search.mapOverlays, + OVERLAY_NAMES, + DEFAULT_OVERLAYS, + ); + const mapView = parseMapView(search, { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }); + + const setSearch = (updater: (prev: typeof search) => typeof search) => { + navigate({ + to: "/manage/wells", + search: (prev) => updater(prev as typeof search), + replace: true, + }); + }; useEffect(() => { if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { @@ -33,31 +87,66 @@ export default function WellSelectionMap({ const wellMarkers = wellQuery.data?.pages.flat() ?? []; + const handleSelectWell = (well: Well) => { + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + well_id: well.id, + add: false, + tab: "map", + }), + replace: true, + }); + }; + return ( <> - + + {/* Base Layers */} - - - + + + {/* Wells Cluster Overlay */} - + setSelectedWell(well), + click: () => handleSelectWell(well), }} icon={getWellIcon(well)} > @@ -101,35 +190,56 @@ export default function WellSelectionMap({ ))} - + + + + {/* Loading first page */} {wellQuery.isLoading && ( - Loading well markers... + + Loading well markers... + )} {/* Loading additional pages */} {wellQuery.isFetchingNextPage && ( - Loading more wells... + + Loading more wells... + )} {wellQuery.isSuccess && wellMarkers.length === 0 && ( - + No wells found for that search. @@ -137,10 +247,14 @@ export default function WellSelectionMap({ {/* Error */} {wellQuery.isError && ( - + Failed to load wells: {wellQuery.error.message} @@ -157,5 +271,4 @@ const getWellIcon = (well: Well) => { return RedMapIcon; } return BlueMapIcon; -} - +}; diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index b91cc863..69323c14 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -1,43 +1,50 @@ -import { Box, Button, Stack } from "@mui/material"; -import { Link } from "react-router-dom"; +import { useEffect, useState, ReactNode, useMemo } from "react"; +import { Link } from "@tanstack/react-router"; import { DataGrid, GridColDef, GridSortModel } from "@mui/x-data-grid"; -import React, { useEffect, useState } from "react"; import { useDebounce } from "use-debounce"; -import { SecurityScope } from "../../interfaces"; -import { useGetWells } from "../../service/ApiServiceNew"; import { useAuthUser } from "react-auth-kit"; -import { SortDirection, WellSortByField } from "../../enums"; -import { Well, WellListQueryParams } from "../../interfaces"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import AddIcon from "@mui/icons-material/Add"; +import { Box, Button, Stack } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/wells"; +import { SecurityScope, Well, WellListQueryParams } from "@/interfaces"; +import { useGetWells } from "@/service"; +import { SortDirection, WellSortByField } from "@/enums"; +import { GridFooterWithButton } from "@/components"; //This is needed for typescript to recognize the slotProps... see https://v6.mui.com/x/react-data-grid/components/#custom-slot-props-with-typescript declare module "@mui/x-data-grid" { interface FooterPropsOverrides { - button: React.ReactNode; + button: ReactNode; } } export default function WellSelectionTable({ - setSelectedWell, wellSearchQueryProp, - setWellAddMode, }: { - setSelectedWell: Function; - setWellAddMode: Function; wellSearchQueryProp: string; }) { + const navigate = useNavigate(); + const search = Route.useSearch(); + const [wellSearchQueryDebounced] = useDebounce(wellSearchQueryProp, 250); - const [wellListQueryParams, setWellListQueryParams] = - useState(); const [gridSortModel, setGridSortModel] = useState(); - const [paginationModel, setPaginationModel] = useState({ - pageSize: 25, - page: 0, - }); const [gridRowCount, setGridRowCount] = useState(100); - const wellsList = useGetWells(wellListQueryParams); + const queryParams = useMemo( + () => ({ + search_string: wellSearchQueryDebounced || undefined, + sort_by: + (gridSortModel?.[0]?.field as WellSortByField) ?? WellSortByField.Name, + sort_direction: + (gridSortModel?.[0]?.sort as SortDirection) ?? SortDirection.Ascending, + limit: search.pageSize, + offset: search.page * search.pageSize, + }), + [wellSearchQueryDebounced, gridSortModel, search.page, search.pageSize], + ); + + const wellsList = useGetWells(queryParams); const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -91,8 +98,22 @@ export default function WellSelectionTable({ renderCell: (params) => { const meters = params.value as Well["meters"]; const links = meters.map((meter, index) => ( - - + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + ({ + meter_id: meter.id, + activity_id: prev.activity_id ?? undefined, + add: prev.add ?? undefined, + tab: prev.tab ?? undefined, + q: prev.q ?? undefined, + filters: prev.filters ?? undefined, + })} + > {meter.serial_number} {index < params.value.length - 1 ? ", " : ""} @@ -102,19 +123,6 @@ export default function WellSelectionTable({ }, }, ]; - // Filter rows based on query params - useEffect(() => { - const newParams = { - search_string: wellSearchQueryDebounced, - sort_by: gridSortModel?.at(0)?.field ?? WellSortByField.Name, - sort_direction: gridSortModel - ? gridSortModel[0]?.sort - : SortDirection.Ascending, - limit: paginationModel.pageSize, - offset: paginationModel.page * paginationModel.pageSize, - }; - setWellListQueryParams(newParams); - }, [wellSearchQueryDebounced, gridSortModel, paginationModel]); useEffect(() => { setGridRowCount(wellsList.data?.total ?? 0); // Update the well count when new list is recieved from API @@ -128,22 +136,57 @@ export default function WellSelectionTable({ row.id} + rowSelectionModel={search.well_id ? [search.well_id] : []} loading={wellsList.isPreviousData || wellsList.isLoading} columns={cols} sortingMode="server" - paginationMode="server" disableColumnMenu keepNonExistentRowsSelected onRowClick={(selectedRow) => { - setSelectedWell( - wellsList.data?.items.find( - (well: Well) => well.id == selectedRow.row.id, - ), + if (search.well_id === selectedRow.row.id) { + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + add: true, + well_id: undefined, + }), + replace: true, + }); + return; + } + + const well = wellsList.data?.items.find( + (well: Well) => well.id == selectedRow.row.id, ); + + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + well_id: well?.id, + add: false, + }), + replace: true, + }); }} onSortModelChange={setGridSortModel} - paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} + pagination + paginationMode="server" + paginationModel={{ page: search.page, pageSize: search.pageSize }} + pageSizeOptions={[10, 25, 50, 100]} + onPaginationModelChange={(m) => { + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + pageSize: m.pageSize, + page: m.pageSize !== (prev as any).pageSize ? 0 : m.page, + }), + replace: true, + }); + }} rowCount={gridRowCount} slots={{ footer: GridFooterWithButton }} slotProps={{ @@ -152,16 +195,30 @@ export default function WellSelectionTable({ diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index 72d19fd2..bd71e394 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Card, CardContent, @@ -9,47 +9,88 @@ import { Box, InputAdornment, } from "@mui/material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; +import { WaterDrop } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; import { Search } from "@mui/icons-material"; -import TabPanel from "../../components/TabPanel"; -import WellSelectionTable from "./WellSelectionTable"; -import WellSelectionMap from "./WellSelectionMap"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { Route } from "@/routes/manage/wells"; +import { CustomCardHeader, ManageBreadcrumbTitle, TabPanel } from "@/components"; -export const WellsTable = ({ - setSelectedWell, - setWellAddMode, -}: { - setSelectedWell: Function; - setWellAddMode: Function; -}) => { - const [wellSearchQuery, setWellSearchQuery] = useState(""); - const [currentTabIndex, setCurrentTabIndex] = useState(0); - const handleTabChange = (_: React.SyntheticEvent, newTabIndex: number) => - setCurrentTabIndex(newTabIndex); +import WellSelectionTable from "@/views/WellManagement/WellSelectionTable"; +import WellSelectionMap from "@/views/WellManagement/WellSelectionMap"; + +const tabToIndex = (tab: "list" | "map") => (tab === "list" ? 0 : 1); +const indexToTab = (i: number): "list" | "map" => (i === 1 ? "map" : "list"); + +export const WellsTable = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + + const [qInput, setQInput] = useState(search.q ?? ""); + useEffect(() => setQInput(search.q ?? ""), [search.q]); + + const currentTabIndex = tabToIndex(search.tab); + const handleTabChange = (_: React.SyntheticEvent, newTabIndex: number) => { + navigate({ + to: "/manage/wells", + search: (prev) => ({ ...(prev as any), tab: indexToTab(newTabIndex) }), + replace: true, + }); + }; + + const applySearch = (value: string) => { + const next = value.trim(); + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + q: next || undefined, + page: 0, // reset paging on new search + }), + replace: true, + }); + }; return ( - + } + icon={WaterDrop} /> - + - + setWellSearchQuery(event.target.value)} + value={qInput} + onChange={(e) => setQInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") applySearch(qInput); + }} + helperText="Press Enter to apply" InputProps={{ startAdornment: ( @@ -62,17 +103,10 @@ export const WellsTable = ({ - + - + diff --git a/frontend/src/views/WellManagement/index.ts b/frontend/src/views/WellManagement/index.ts new file mode 100644 index 00000000..db545593 --- /dev/null +++ b/frontend/src/views/WellManagement/index.ts @@ -0,0 +1 @@ +export * from './WellManagementView' diff --git a/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx b/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx deleted file mode 100644 index a4d4155d..00000000 --- a/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useState } from "react"; -import { - GridActionsCellItem, - GridActionsCellItemProps, -} from "@mui/x-data-grid"; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from "@mui/material"; - -export function DeleteWorkOrder({ - deleteUser, - deleteMessage, - ...props -}: GridActionsCellItemProps & { - deleteUser: () => void; - deleteMessage?: string; -}) { - const [open, setOpen] = useState(false); - - return ( - <> - setOpen(true)} /> - setOpen(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - {deleteMessage} - - - This action cannot be undone. - - - - - - - - - ); -} diff --git a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx deleted file mode 100644 index 3532820b..00000000 --- a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useState } from "react"; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - TextField, -} from "@mui/material"; -import { - MeterListDTO, - NewWorkOrder, -} from "../../interfaces"; -import MeterSelection from "../../components/MeterSelection"; - -interface NewWorkOrderModalProps { - openNewWorkOrderModal: boolean; - closeNewWorkOrderModal: () => void; - submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; -} - -export function NewWorkOrderModal({ - openNewWorkOrderModal, - closeNewWorkOrderModal, - submitNewWorkOrder, -}: NewWorkOrderModalProps) { - const [workOrderTitle, setWorkOrderTitle] = useState(""); - const [workOrderMeter, setWorkOrderMeter] = useState< - MeterListDTO | undefined - >(); - const [meterSelectionError, setMeterSelectionError] = - useState(false); - const [titleError, setTitleError] = useState(false); - - function handleSubmit() { - if (!workOrderMeter) { - setMeterSelectionError(true); - return; - } - if (!workOrderTitle) { - setTitleError(true); - return; - } - - //If both fields are filled, submit the work order - //Create a new work order object - const newWorkOrder: NewWorkOrder = { - date_created: new Date(), - meter_id: workOrderMeter.id, - title: workOrderTitle, - }; - submitNewWorkOrder(newWorkOrder); - closeNewWorkOrderModal(); - - //Reset the form - setWorkOrderMeter(undefined); - setWorkOrderTitle(""); - } - - const handleCancel = () => { - closeNewWorkOrderModal(); - setWorkOrderMeter(undefined); - setWorkOrderTitle(""); - }; - - return ( - - Create a New Work Order - - - To create a new work order, please select a meter and title. Other - fields can be edited as needed after creation. - - - setWorkOrderTitle(event.target.value)} - error={titleError} - helperText={titleError ? "Title cannot be empty" : ""} - /> - - - - - - - ); -} diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 56cf9be5..468eeb5b 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -1,154 +1,235 @@ -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 { useEffect, useMemo, useState } from "react"; +import { Delete, Add, Handyman, Clear } from "@mui/icons-material"; import { DataGrid, GridColDef, GridRowModel, GridRenderCellParams, GridRowId, - GridFilterItem, } from "@mui/x-data-grid"; +import { useAuthUser } from "react-auth-kit"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useGetWorkOrders, useUpdateWorkOrder, useGetUserList, useDeleteWorkOrder, useCreateWorkOrder, -} from "../../service/ApiServiceNew"; -import { WorkOrderStatus } from "../../enums"; +} from "@/service/ApiServiceNew"; +import { WorkOrderStatus } from "@/enums"; import { + Autocomplete, Box, Button, IconButton, Stack, + TextField, } from "@mui/material"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { GridFooterWithButton, UserAvatar } from "@/components"; +import { Create } from "@/components/Modals/WorkOrders"; +import { MeterActivity, NewWorkOrder, User } from "@/interfaces"; +import { useSnackbar } from "notistack"; +import { Route } from "@/routes/workorders"; import { - MeterActivity, - NewWorkOrder, - SecurityScope, -} from "../../interfaces"; -import { useAuthUser } from "react-auth-kit"; -import { Link, createSearchParams } from "react-router-dom"; -import { DeleteWorkOrder } from "./DeleteWorkOrder"; -import { NewWorkOrderModal } from "./NewWorkOrderModal"; - -export default function WorkOrdersTable() { - 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(); + getRoleLabel, + sortUsersByRoleThenName, +} from "@/utils/UserRoleGrouping"; - const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = - useState(false); +const STATUS_OPTIONS: WorkOrderStatus[] = [ + WorkOrderStatus.Open, + WorkOrderStatus.Review, + WorkOrderStatus.Closed, +]; + +export const WorkOrdersTable = () => { + const { enqueueSnackbar } = useSnackbar(); + + const navigate = useNavigate(); + const search = Route.useSearch(); + const { status, assigned_user_id, q, work_order_id, page, pageSize } = search; - //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 getUserFromID = (id: number | undefined) => { - return userList.data?.find((user) => user.id === id)?.full_name ?? ""; - }; + const user = authUser(); + const currentUserId: number | undefined = user?.id; + + const hasAdminScope = + user?.user_role.security_scopes + .map((s: any) => s.scope_string) + .includes("admin") ?? false; + + const userList = useGetUserList(); + + const sortedUsers = useMemo(() => { + return sortUsersByRoleThenName((userList.data ?? []) as User[]); + }, [userList.data]); + + const getUserByID = (id: number | undefined) => + userList.data?.find((u) => u.id === id); + + const getAvatarRole = (user: User | null | undefined) => + user ? getRoleLabel(user) : undefined; + + const getUserFromID = (id: number | undefined) => + getUserByID(id)?.full_name ?? ""; + + const getUserIDfromName = (name: string) => + userList.data?.find((u) => u.full_name === name)?.id ?? undefined; - const getUserIDfromName = (name: string) => { - return userList.data?.find((user) => user.full_name === name)?.id ?? 0; + // Helper to update URL search + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/workorders", + search: (prev) => updater(prev as any), + replace: true, + }); }; - const current_user_name = getUserFromID(authUser()?.id); - var initialFilter: GridFilterItem[] = []; //No filter if admin - var status_options = ["Open", "Review", "Closed"]; - - //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" }]; - } - - //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([ + if (!user) return; + + // If deep-linking by ID(s), do not override + if (work_order_id?.length) { + navigate({ + to: "/workorders", + search: (prev) => ({ + ...prev, + status: STATUS_OPTIONS, + }), + replace: true, + }); + return; + } + + // Only techs get forced assigned filter + const needsTechAssignedDefault = + !hasAdminScope && currentUserId && !assigned_user_id; + + if (!needsTechAssignedDefault) return; + + navigate({ + to: "/workorders", + search: (prev) => ({ + ...prev, + assigned_user_id: currentUserId, + page: 0, + }), + replace: true, + }); + }, [ + user, + hasAdminScope, + currentUserId, + assigned_user_id, + work_order_id, + navigate, + ]); + + // Local input state for q (so typing doesn't spam URL) + const [qInput, setQInput] = useState(q ?? ""); + useEffect(() => setQInput(q ?? ""), [q]); + + const workOrderList = useGetWorkOrders( + { + filter_by_status: status ?? [ WorkOrderStatus.Open, WorkOrderStatus.Review, - WorkOrderStatus.Closed, - ]); - } else { - setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review]); - } - }, [hasAdminScope]); // Dependency array ensures this runs only when hasAdminScope changes + ], + work_order_id, + assigned_user_id, + q, + }, + { refetchInterval: false }, + ); - const handleRowUpdate = ( + const updateWorkOrder = useUpdateWorkOrder(); + const deleteWorkOrder = useDeleteWorkOrder(() => + console.log("Work order deleted"), + ); + const createWorkOrder = useCreateWorkOrder(); + + const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = + useState(false); + + const handleRowUpdate = async ( 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 (!updatedField) return originalRow; + + let field_data: any; - //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 = { + return updateWorkOrder.mutateAsync({ 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); + [updatedField]: field_data, + }); }; const handleProcessRowUpdateError = (error: Error): void => { - console.error("Error updating work order", error); + console.error(error); + enqueueSnackbar(error?.message || "Failed to update work order.", { + variant: "error", + }); }; const handleDeleteClick = (id: GridRowId) => { - let deletepromise = deleteWorkOrder.mutateAsync(id as number); - deletepromise.then(() => { - //Get the updated rows - workOrderList.refetch(); + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { + enqueueSnackbar("Invalid work order ID. Delete aborted.", { + variant: "error", + }); + return; + } + if (!window.confirm(`Are you sure you want to delete work order ${id}?`)) + return; + + deleteWorkOrder.mutate(id, { + onSuccess: () => { + enqueueSnackbar(`Work order ${id} deleted successfully.`, { + variant: "success", + }); + workOrderList.refetch(); + }, + onError: (error: any) => { + enqueueSnackbar(error?.message || "Failed to delete work order.", { + variant: "error", + }); + }, }); }; const handleNewWorkOrder = (newWorkOrder: NewWorkOrder) => { - createWorkOrder.mutateAsync(newWorkOrder).then(() => { - workOrderList.refetch(); - }); + createWorkOrder + .mutateAsync(newWorkOrder) + .then(() => workOrderList.refetch()); + }; + + const clearFilters = () => { + setQInput(""); + setSearch((prev) => ({ + ...prev, + page: 0, + q: undefined, + work_order_id: undefined, + status: [WorkOrderStatus.Open, WorkOrderStatus.Review], + assigned_user_id: hasAdminScope ? undefined : currentUserId, + })); }; const columns: GridColDef[] = [ { field: "work_order_id", headerName: "ID", + type: "number", flex: 1, - minWidth: 50 + minWidth: 50, }, { field: "date_created", @@ -166,10 +247,15 @@ export default function WorkOrdersTable() { renderCell: (params) => { return ( ({ + meter_id: params.row.meter_id, + activity_id: prev.activity_id ?? undefined, + add: prev.add ?? undefined, + tab: prev.tab ?? undefined, + q: prev.q ?? undefined, + filters: prev.filters ?? undefined, + })} > {params.value} @@ -203,7 +289,7 @@ export default function WorkOrdersTable() { flex: 1, minWidth: 125, type: "singleSelect", - valueOptions: status_options, + valueOptions: STATUS_OPTIONS, editable: true, }, { field: "notes", headerName: "Notes", width: 300, editable: true }, @@ -217,10 +303,15 @@ export default function WorkOrdersTable() { const links = activities.map((activity, index) => ( ({ + meter_id: activity.meter_id, + activity_id: activity.id, + add: prev.add ?? undefined, + tab: prev.tab ?? undefined, + q: prev.q ?? undefined, + filters: prev.filters ?? undefined, + })} > {activity.id} @@ -236,9 +327,27 @@ export default function WorkOrdersTable() { headerName: "Technician Assigned", flex: 2, minWidth: 200, + cellClassName: "work-order-top-cell", valueGetter: (id: number) => getUserFromID(id), + renderCell: (params) => { + const assignedUser = getUserByID(params.row.assigned_user_id); + + if (!assignedUser) return ""; + + return ( + + + {assignedUser.full_name} + + ); + }, type: "singleSelect", - valueOptions: userList.data?.map((user) => user.full_name) ?? [], + valueOptions: sortedUsers.map((user) => user.full_name), editable: hasAdminScope, }, { @@ -269,6 +378,7 @@ export default function WorkOrdersTable() { flex: 1, minWidth: 100, sortable: false, + cellClassName: "work-order-top-cell", renderCell: (params: GridRenderCellParams) => { const isOpen = params.row.status === "Open"; @@ -276,93 +386,248 @@ export default function WorkOrdersTable() { {isOpen && ( + e.stopPropagation()} + onClick={(e: any) => e.stopPropagation()} + > + + + + + )} + {hasAdminScope && ( { + e.stopPropagation(); + handleDeleteClick(params.id); + }} > - + )} - } - deleteMessage={`Delete work order ${params.id}?`} - label="Delete" - deleteUser={() => handleDeleteClick(params.id)} - showInMenu={false} - disabled={!hasAdminScope} - /> ); }, }, ]; + const rows = workOrderList.data ?? []; + const loading = workOrderList.isLoading || workOrderList.isFetching; + const selectedAssignedUser = assigned_user_id + ? (sortedUsers.find((u) => u.id === assigned_user_id) ?? null) + : null; + 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, + + + + + setSearch((p) => ({ + ...p, + status: value.length ? value : [WorkOrderStatus.Open], + page: 0, + })) + } + sx={{ minWidth: 260 }} + renderInput={(params) => } + /> + {hasAdminScope && ( + getRoleLabel(option)} + getOptionLabel={(option: User) => option.full_name ?? ""} + isOptionEqualToValue={(option: User, value: User) => + option.id === value.id + } + value={selectedAssignedUser} + onChange={(_, user) => { + const id = user?.id; + setSearch((p) => ({ ...p, assigned_user_id: id, page: 0 })); + }} + sx={{ minWidth: 260 }} + renderOption={(props, option) => ( +
  • + + + {option.full_name} + +
  • + )} + renderInput={(params) => { + const { InputProps, ...rest } = params; + const startAdornment = selectedAssignedUser ? ( + <> + + {InputProps.startAdornment} + + ) : InputProps.startAdornment; + + return ( + + ); + }} + /> + )} + setQInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + const next = qInput.trim(); + setSearch((p) => ({ ...p, q: next || undefined, page: 0 })); + } + }} + sx={{ minWidth: 260 }} + helperText="Press Enter to apply" + /> + + + + +
    +
    + + - - - ), - }, - }} - /> - setIsNewWorkOrderModalOpen(false)} + + + ), + }, + }} + /> + + setIsNewWorkOrderModalOpen(false)} submitNewWorkOrder={handleNewWorkOrder} />
    ); -} +}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx deleted file mode 100644 index c3ad9394..00000000 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 ( - - - - - - - - - ); -} diff --git a/frontend/src/views/WorkOrders/index.ts b/frontend/src/views/WorkOrders/index.ts new file mode 100644 index 00000000..69241b86 --- /dev/null +++ b/frontend/src/views/WorkOrders/index.ts @@ -0,0 +1 @@ +export * from "./WorkOrdersTable"; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index 38f6ed6f..ec225209 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -1,4 +1,16 @@ +export * from "./Activities"; export * from "./Backups"; export * from "./Home"; +export * from "./InsufficientPermView"; export * from "./Login"; +export * from "./Manage"; +export * from "./Meters"; +export * from "./NotFound"; +export * from "./Notifications"; +export * from "./Parts"; +export * from "./Reports"; +export * from "./RouteErrorView"; export * from "./Settings"; +export * from "./UserManagement"; +export * from "./WellManagement"; +export * from "./WorkOrders"; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ec66aad9..4b37af04 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,19 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; import path from "path"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); return { - plugins: [react()], + plugins: [ + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + }), + react(), + ], server: { proxy: { "/api": { diff --git a/migrations/20260128071539_parts_inventory_counts.down.sql b/migrations/20260128071539_parts_inventory_counts.down.sql new file mode 100644 index 00000000..21fb6249 --- /dev/null +++ b/migrations/20260128071539_parts_inventory_counts.down.sql @@ -0,0 +1,16 @@ +-- Rename initial_count back to count +ALTER TABLE public."Parts" +RENAME COLUMN initial_count TO count; + +-- Allow NULLs again (original behavior) +ALTER TABLE public."Parts" +ALTER COLUMN count DROP NOT NULL; + +ALTER TABLE public."Parts" +ALTER COLUMN count DROP DEFAULT; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN count DROP NOT NULL; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN count DROP DEFAULT; diff --git a/migrations/20260128071539_parts_inventory_counts.up.sql b/migrations/20260128071539_parts_inventory_counts.up.sql new file mode 100644 index 00000000..c03ccbb4 --- /dev/null +++ b/migrations/20260128071539_parts_inventory_counts.up.sql @@ -0,0 +1,27 @@ +-- Rename Parts.count -> Parts.initial_count +ALTER TABLE public."Parts" +RENAME COLUMN count TO initial_count; + +-- Ensure initial_count is NOT NULL and defaults to 0 +UPDATE public."Parts" +SET initial_count = 0 +WHERE initial_count IS NULL; + +ALTER TABLE public."Parts" +ALTER COLUMN initial_count SET NOT NULL; + +ALTER TABLE public."Parts" +ALTER COLUMN initial_count SET DEFAULT 0; + +-- Normalize PartsUsed.count to 1 +UPDATE public."PartsUsed" +SET count = 1 +WHERE count IS DISTINCT FROM 1; + +-- Enforce count semantics going forward +ALTER TABLE public."PartsUsed" +ALTER COLUMN count SET NOT NULL; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN count SET DEFAULT 1; + diff --git a/migrations/20260128080544_create_parts_added.down.sql b/migrations/20260128080544_create_parts_added.down.sql new file mode 100644 index 00000000..ad8d4599 --- /dev/null +++ b/migrations/20260128080544_create_parts_added.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public."PartsAdded"; diff --git a/migrations/20260128080544_create_parts_added.up.sql b/migrations/20260128080544_create_parts_added.up.sql new file mode 100644 index 00000000..87dbdb2f --- /dev/null +++ b/migrations/20260128080544_create_parts_added.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE public."PartsAdded" ( + id serial4 NOT NULL, + part_id int4 NOT NULL, + count int4 NOT NULL DEFAULT 1, + date date NOT NULL DEFAULT CURRENT_DATE, + note varchar NULL, + + CONSTRAINT "PartsAdded_pkey" PRIMARY KEY (id), + CONSTRAINT "PartsAdded_part_id_fkey" + FOREIGN KEY (part_id) + REFERENCES public."Parts"(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +-- Helpful indexes +CREATE INDEX "ix_PartsAdded_part_id" ON public."PartsAdded" USING btree (part_id); +CREATE INDEX "ix_PartsAdded_date" ON public."PartsAdded" USING btree (date); diff --git a/migrations/20260317221411_create_notifications_table.down.sql b/migrations/20260317221411_create_notifications_table.down.sql new file mode 100644 index 00000000..bd2a4683 --- /dev/null +++ b/migrations/20260317221411_create_notifications_table.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS public.ix_notifications_notification_type_id; +DROP INDEX IF EXISTS public.ix_notifications_created_at; +DROP INDEX IF EXISTS public.ix_notifications_user_id_is_read; +DROP INDEX IF EXISTS public.ix_notifications_user_id; +DROP INDEX IF EXISTS public.ix_notifications_id; + +DROP TABLE IF EXISTS public.notifications; +DROP TABLE IF EXISTS public.notification_type_lu; diff --git a/migrations/20260317221411_create_notifications_table.up.sql b/migrations/20260317221411_create_notifications_table.up.sql new file mode 100644 index 00000000..d4f939f1 --- /dev/null +++ b/migrations/20260317221411_create_notifications_table.up.sql @@ -0,0 +1,54 @@ +CREATE TABLE public.notification_type_lu ( + id serial4 NOT NULL, + "name" varchar(50) NOT NULL, + description text NULL, + CONSTRAINT notification_type_lu_pkey PRIMARY KEY (id), + CONSTRAINT notification_type_lu_name_key UNIQUE ("name") +); + +INSERT INTO public.notification_type_lu ("name", description) VALUES + ('system', 'General system notification'), + ('warning', 'A Warning that may require user attention'), + ('message', 'A User-to-user message'), + ('approval', 'Approval required or granted notification'), + ('work_order', 'A work order update'), + ('owner_change', 'An ownership change'), + ('system_improvement', 'Notification about improvements, enhancements, or updates to the application'); + +CREATE TABLE public.notifications ( + id serial4 NOT NULL, + user_id int4 NOT NULL, + notification_type_id int4 NOT NULL, + title varchar(255) NOT NULL, + message text NOT NULL, + link varchar(500) NULL, + is_read bool NOT NULL DEFAULT false, + created_at timestamp NOT NULL DEFAULT now(), + read_at timestamp NULL, + CONSTRAINT notifications_pkey PRIMARY KEY (id), + CONSTRAINT fk_notifications_user + FOREIGN KEY (user_id) + REFERENCES public."Users"(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT fk_notifications_type + FOREIGN KEY (notification_type_id) + REFERENCES public.notification_type_lu(id) + ON DELETE RESTRICT + ON UPDATE CASCADE +); + +CREATE INDEX ix_notifications_id + ON public.notifications USING btree (id); + +CREATE INDEX ix_notifications_user_id + ON public.notifications USING btree (user_id); + +CREATE INDEX ix_notifications_user_id_is_read + ON public.notifications USING btree (user_id, is_read); + +CREATE INDEX ix_notifications_created_at + ON public.notifications USING btree (created_at); + +CREATE INDEX ix_notifications_notification_type_id + ON public.notifications USING btree (notification_type_id); diff --git a/migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql b/migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql new file mode 100644 index 00000000..68e42a9f --- /dev/null +++ b/migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS public.ix_notifications_created_by; + +ALTER TABLE public.notifications +DROP CONSTRAINT IF EXISTS fk_notifications_created_by; + +ALTER TABLE public.notifications +DROP COLUMN IF EXISTS created_by; diff --git a/migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql b/migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql new file mode 100644 index 00000000..88f069d6 --- /dev/null +++ b/migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE public.notifications +ADD COLUMN created_by int4 NULL; + +ALTER TABLE public.notifications +ADD CONSTRAINT fk_notifications_created_by +FOREIGN KEY (created_by) +REFERENCES public."Users"(id) +ON DELETE SET NULL +ON UPDATE CASCADE; + +CREATE INDEX ix_notifications_created_by +ON public.notifications USING btree (created_by); diff --git a/scripts/export_chloride_concentrations.sql b/scripts/export_chloride_concentrations.sql new file mode 100644 index 00000000..49d5e2af --- /dev/null +++ b/scripts/export_chloride_concentrations.sql @@ -0,0 +1,54 @@ +/* + Chloride Concentration Results Export (Hydrologist-Friendly CSV) + + This query exports all chloride concentration sample results + from the WellMeasurements table in a format suitable for + non-technical users (hydrologists, consultants, regulators). + + - Sample Result Date is formatted as YYYY-MM-DD (date only) + - Result Value and Result Unit are in separate columns + - Well and Location identifiers are human-readable (no DB IDs) + - Geometry is exported as WKT for GIS compatibility +*/ +SELECT + "Well Name", + "RA Number", + "Sample Result Date", + "Sample Result Value", + "Sample Result Unit", + "Parameter", + "Casing", + "Total Depth", + "Location Name", + "Latitude", + "Longitude", + "Location Geometry (WKT)" +FROM ( + SELECT + l.name AS "Location Name", + w.name AS "Well Name", + w.ra_number AS "RA Number", + w.casing AS "Casing", + w.total_depth AS "Total Depth", + to_char(wm."timestamp"::date, 'YYYY-MM-DD') AS "Sample Result Date", + opt.name AS "Parameter", + wm.value AS "Sample Result Value", + u.name_short AS "Sample Result Unit", + l.latitude AS "Latitude", + l.longitude AS "Longitude", + ST_AsText(l.geom) AS "Location Geometry (WKT)" + FROM public."WellMeasurements" wm + JOIN public."Units" u + ON u.id = wm.unit_id + JOIN public."ObservedPropertyTypeLU" opt + ON opt.id = wm.observed_property_id + JOIN public."Wells" w + ON w.id = wm.well_id + JOIN public."Locations" l + ON l.id = w.location_id + WHERE wm.observed_property_id = 5 +) t +ORDER BY + t."Sample Result Date" ASC, + t."Well Name" ASC, + t."Location Name" ASC; diff --git a/scripts/export_meter_readings.sql b/scripts/export_meter_readings.sql new file mode 100644 index 00000000..0a05fae8 --- /dev/null +++ b/scripts/export_meter_readings.sql @@ -0,0 +1,56 @@ +/* + Meter Readings Export (Hydrologist-Friendly CSV) + + This query exports all "Meter reading" observations from the + MeterObservations table in a format suitable for non-technical + users (hydrologists, consultants, regulators). + + - Reading Date is formatted as YYYY-MM-DD (date only) + - Reading Value and Reading Unit are in separate columns + - Well and Location identifiers are human-readable (no DB IDs) + - Includes Meter ID for traceability (optional, but helpful) + - Join to Wells is performed via shared Location (w.location_id = mo.location_id) +*/ +SELECT + "Well Name", + "RA Number", + "Well Depth (ft)", + "Meter Reading Date", + "Meter Reading Value", + "Meter Reading Unit", + "Parameter", + "Location Name", + "Latitude", + "Longitude", + "Location Geometry (WKT)", + "Meter ID" +FROM ( + SELECT + l.name AS "Location Name", + w.name AS "Well Name", + w.ra_number AS "RA Number", + w.total_depth AS "Well Depth (ft)", + to_char(mo."timestamp"::date, 'YYYY-MM-DD') AS "Meter Reading Date", + opt.name AS "Parameter", + mo.value AS "Meter Reading Value", + u.name_short AS "Meter Reading Unit", + l.latitude AS "Latitude", + l.longitude AS "Longitude", + ST_AsText(l.geom) AS "Location Geometry (WKT)", + mo.meter_id AS "Meter ID" + FROM public."MeterObservations" mo + JOIN public."Units" u + ON u.id = mo.unit_id + JOIN public."ObservedPropertyTypeLU" opt + ON opt.id = mo.observed_property_type_id + JOIN public."Locations" l + ON l.id = mo.location_id + JOIN public."Wells" w + ON w.location_id = mo.location_id + WHERE mo.observed_property_type_id = 1 -- Meter reading +) t +ORDER BY + t."Meter Reading Date" ASC, + t."Well Name" ASC, + t."Location Name" ASC; +