From ba4c167fcdfa8fc2b694f61dc1d74447fe43f2b6 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 6 May 2026 21:45:57 -0500 Subject: [PATCH 1/5] feat(PartsUsed): Add skeleton loaders for parts used pg --- .../src/views/Reports/PartsUsed/index.tsx | 123 ++++++++++-------- 1 file changed, 68 insertions(+), 55 deletions(-) diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index caaff1c8..a28a69b1 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -11,6 +11,7 @@ import { Switch, TextField, Tooltip, + Skeleton, } from "@mui/material"; import { useNavigate } from "@tanstack/react-router"; import { Controller, useForm } from "react-hook-form"; @@ -452,17 +453,21 @@ export const PartsUsedReportView = () => { /> - option.type.name} - /> + {partsQuery.isLoading ? ( + + ) : ( + option.type.name} + /> + )} { - { - // Convert stored IDs to Part objects for the `value` prop - const selectedParts = (partsQuery?.data ?? []).filter( - (part) => field?.value?.includes(part.id), - ); - - return ( - option.part_type?.name ?? "Other"} - getOptionLabel={(option: Part) => - `${option.part_number} ${option.description}` - } - isOptionEqualToValue={(a: Part, b: Part) => a.id === b.id} - value={selectedParts} - onChange={(_, selectedOptions) => - field.onChange(selectedOptions.map((p) => p.id)) - } - filterOptions={(options: Part[], state: any) => - options.filter((opt) => - `${opt.part_number} ${opt.description}` - .toLowerCase() - .includes(state.inputValue.toLowerCase()), - ) - } - loading={partsQuery.isLoading} - renderInput={(params) => ( - - )} - /> - ); - }} - /> + {partsQuery.isLoading ? ( + + ) : ( + { + // Convert stored IDs to Part objects for the `value` prop + const selectedParts = (partsQuery?.data ?? []).filter( + (part) => field?.value?.includes(part.id), + ); + + return ( + + option.part_type?.name ?? "Other" + } + getOptionLabel={(option: Part) => + `${option.part_number} ${option.description}` + } + isOptionEqualToValue={(a: Part, b: Part) => + a.id === b.id + } + value={selectedParts} + onChange={(_, selectedOptions) => + field.onChange(selectedOptions.map((p) => p.id)) + } + filterOptions={(options: Part[], state: any) => + options.filter((opt) => + `${opt.part_number} ${opt.description}` + .toLowerCase() + .includes(state.inputValue.toLowerCase()), + ) + } + loading={partsQuery.isLoading} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + )} Date: Wed, 6 May 2026 22:01:07 -0500 Subject: [PATCH 2/5] feat(PartsUsed): Add select all / deselect all toggle button --- .../src/views/Reports/PartsUsed/index.tsx | 132 +++++++++++------- 1 file changed, 85 insertions(+), 47 deletions(-) diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index a28a69b1..ae7a6e46 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -413,6 +413,27 @@ export const PartsUsedReportView = () => { }); }; + const hasSelectedParts = selectedPartIds.length > 0; + + const handleToggleParts = () => { + if (hasSelectedParts) { + setValue("part_types", [], { shouldDirty: true, shouldValidate: true }); + setValue("parts", [], { shouldDirty: true, shouldValidate: true }); + return; + } + + setValue("part_types", partTypeOptions, { + shouldDirty: true, + shouldValidate: true, + }); + + setValue( + "parts", + (partsQuery.data ?? []).map((part) => part.id), + { shouldDirty: true, shouldValidate: true }, + ); + }; + return ( @@ -500,54 +521,71 @@ export const PartsUsedReportView = () => { {partsQuery.isLoading ? ( ) : ( - { - // Convert stored IDs to Part objects for the `value` prop - const selectedParts = (partsQuery?.data ?? []).filter( - (part) => field?.value?.includes(part.id), - ); - - return ( - - option.part_type?.name ?? "Other" - } - getOptionLabel={(option: Part) => - `${option.part_number} ${option.description}` - } - isOptionEqualToValue={(a: Part, b: Part) => - a.id === b.id - } - value={selectedParts} - onChange={(_, selectedOptions) => - field.onChange(selectedOptions.map((p) => p.id)) - } - filterOptions={(options: Part[], state: any) => - options.filter((opt) => - `${opt.part_number} ${opt.description}` - .toLowerCase() - .includes(state.inputValue.toLowerCase()), - ) - } - loading={partsQuery.isLoading} - renderInput={(params) => ( - + + { + // Convert stored IDs to Part objects for the `value` prop + const selectedParts = (partsQuery?.data ?? []).filter( + (part) => field?.value?.includes(part.id), + ); + + return ( + + option.part_type?.name ?? "Other" + } + getOptionLabel={(option: Part) => + `${option.part_number} ${option.description}` + } + isOptionEqualToValue={(a: Part, b: Part) => + a.id === b.id + } + value={selectedParts} + onChange={(_, selectedOptions) => + field.onChange(selectedOptions.map((p) => p.id)) + } + filterOptions={(options: Part[], state: any) => + options.filter((opt) => + `${opt.part_number} ${opt.description}` + .toLowerCase() + .includes(state.inputValue.toLowerCase()), + ) + } + loading={partsQuery.isLoading} + renderInput={(params) => ( + + )} /> - )} - /> - ); - }} - /> + ); + }} + /> + + + + + )} From c9988e7e4e3d53c4971899cf73840b9b4ddbebf5 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 7 May 2026 11:14:59 -0500 Subject: [PATCH 3/5] feat(services/work_orders): Notify Admins & assigned user or work order creation & modification --- api/services/work_orders.py | 109 +++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/api/services/work_orders.py b/api/services/work_orders.py index a77dbc1a..ee8cae64 100644 --- a/api/services/work_orders.py +++ b/api/services/work_orders.py @@ -6,19 +6,16 @@ from sqlalchemy.orm import Session, joinedload from api.models.meter import Meters, MeterActivities -from api.models.user import Users +from api.models.user import Notifications, NotificationTypeLU, Users from api.models.work_order import workOrders, workOrderStatusLU from api.schemas import meter def _work_order_query(): - return ( - select(workOrders) - .options( - joinedload(workOrders.status), - joinedload(workOrders.meter).joinedload(Meters.status), - joinedload(workOrders.assigned_user), - ) + return select(workOrders).options( + joinedload(workOrders.status), + joinedload(workOrders.meter).joinedload(Meters.status), + joinedload(workOrders.assigned_user), ) @@ -175,13 +172,23 @@ def create_work_order( try: db.add(work_order) + db.flush() + + _create_work_order_notifications( + db=db, + work_order=work_order, + action="created", + ) + db.commit() except IntegrityError: raise HTTPException( status_code=409, detail="Title empty or already exists for this meter." ) - work_order = db.scalars(_work_order_query().where(workOrders.id == work_order.id)).first() + work_order = db.scalars( + _work_order_query().where(workOrders.id == work_order.id) + ).first() return _serialize_work_order(work_order) @@ -196,7 +203,9 @@ def update_work_order( notes=patch_work_order_form.notes, ) - update_scope = "Technician" if comparison_work_order == patch_work_order_form else "Admin" + update_scope = ( + "Technician" if comparison_work_order == patch_work_order_form else "Admin" + ) if user.user_role.name not in [update_scope, "Admin"]: raise HTTPException( @@ -236,9 +245,20 @@ def update_work_order( work_order.assigned_user_id = patch_work_order_form.assigned_user_id try: + db.flush() + + _create_work_order_notifications( + db=db, + work_order=work_order, + action="updated", + created_by_user_id=user.id, + ) + db.commit() except IntegrityError: - raise HTTPException(status_code=409, detail="Title already exists for this meter.") + raise HTTPException( + status_code=409, detail="Title already exists for this meter." + ) work_order = db.scalars( _work_order_query() @@ -249,7 +269,9 @@ def update_work_order( select(MeterActivities).where(MeterActivities.work_order_id == work_order.id) ).all() - return _serialize_work_order(work_order, associated_activities=list(associated_activities)) + return _serialize_work_order( + work_order, associated_activities=list(associated_activities) + ) def delete_work_order(db: Session, work_order_id: int): @@ -260,7 +282,66 @@ def delete_work_order(db: Session, work_order_id: int): if not work_order: raise HTTPException(status_code=404, detail="Work order not found.") - db.delete(work_order) - db.commit() + try: + db.delete(work_order) + db.flush() + + _create_work_order_notifications( + db=db, + work_order=work_order, + action="deleted", + ) + + db.commit() + except IntegrityError: + raise HTTPException(status_code=409, detail="Failed to delete work order.") return {"status": "success"} + + +def _create_work_order_notifications( + db: Session, + work_order: workOrders, + action: str, # "created" or "updated" or "deleted" + created_by_user_id: int | None = None, +): + notification_type = db.scalars( + select(NotificationTypeLU).where(NotificationTypeLU.name == "work_order") + ).first() + + if not notification_type: + return + + admin_user_ids = db.scalars( + select(Users.id) + .join(Users.user_role) + .where( + Users.user_role.has(name="Admin"), + Users.disabled.is_(False), + ) + ).all() + + recipient_user_ids = set(admin_user_ids) + + if work_order.assigned_user_id: + recipient_user_ids.add(work_order.assigned_user_id) + + if not recipient_user_ids: + return + + title = f"Work order {action}: {work_order.title}" + message = f"Work order #{work_order.id} has been {action}." + + notifications = [ + Notifications( + user_id=user_id, + notification_type_id=notification_type.id, + created_by=created_by_user_id, + title=title, + message=message, + link=f"/workorders?work_order_id={work_order.id}", + ) + for user_id in recipient_user_ids + ] + + db.add_all(notifications) From 530084eec0d4bd36935aa50f8d27714d272cec5b Mon Sep 17 00:00:00 2001 From: Tyler Martinez Date: Tue, 12 May 2026 16:26:05 -0600 Subject: [PATCH 4/5] chore(package-lock): Update fast-uri pkg number --- frontend/package-lock.json | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67879093..cef30c7e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6285,9 +6285,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6297,8 +6297,7 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ], - "license": "BSD-3-Clause" + ] }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -8420,9 +8419,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -8437,7 +8436,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", From 7a28b5d20a9234f00fa76a2ce46f138289ffebcb Mon Sep 17 00:00:00 2001 From: Tyler Martinez Date: Tue, 12 May 2026 16:26:05 -0600 Subject: [PATCH 5/5] chore(package-lock): Update fast-uri pkg number --- frontend/package-lock.json | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67879093..cef30c7e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6285,9 +6285,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -6297,8 +6297,7 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ], - "license": "BSD-3-Clause" + ] }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -8420,9 +8419,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -8437,7 +8436,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1",