Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 95 additions & 14 deletions api/services/work_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)


Expand Down Expand Up @@ -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)


Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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):
Expand All @@ -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)
16 changes: 7 additions & 9 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

159 changes: 105 additions & 54 deletions frontend/src/views/Reports/PartsUsed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -412,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 (
<BackgroundBox>
<Card sx={{ height: "fit-content" }}>
Expand Down Expand Up @@ -452,17 +474,21 @@ export const PartsUsedReportView = () => {
/>
</Grid>
<Grid item xs sx={{ flexGrow: 1 }}>
<ControlledSelect
sx={{ width: "100%" }}
size="small"
label="Part Types"
control={control}
name="part_types"
multiple
disabled={partsQuery.isFetching}
options={partTypeOptions}
getOptionLabel={(option: any) => option.type.name}
/>
{partsQuery.isLoading ? (
<Skeleton variant="rounded" width="100%" height={40} />
) : (
<ControlledSelect
sx={{ width: "100%" }}
size="small"
label="Part Types"
control={control}
name="part_types"
multiple
disabled={partsQuery.isFetching}
options={partTypeOptions}
getOptionLabel={(option: any) => option.type.name}
/>
)}
</Grid>
<Grid
item
Expand Down Expand Up @@ -492,50 +518,75 @@ export const PartsUsedReportView = () => {
</Tooltip>
</Grid>
<Grid item xs={12}>
<Controller
name="parts"
control={control}
render={({ field }) => {
// Convert stored IDs to Part objects for the `value` prop
const selectedParts = (partsQuery?.data ?? []).filter(
(part) => field?.value?.includes(part.id),
);

return (
<Autocomplete
multiple
disableClearable
options={groupedFilteredParts}
groupBy={(option: Part) => 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) => (
<TextField
{...params}
size="small"
sx={{ width: "100%" }}
label="Parts"
placeholder="Begin typing to search"
/>
)}
{partsQuery.isLoading ? (
<Skeleton variant="rounded" width="100%" height={40} />
) : (
<Grid container spacing={2} alignItems="center">
<Grid item xs>
<Controller
name="parts"
control={control}
render={({ field }) => {
// Convert stored IDs to Part objects for the `value` prop
const selectedParts = (partsQuery?.data ?? []).filter(
(part) => field?.value?.includes(part.id),
);

return (
<Autocomplete
multiple
disableClearable
options={groupedFilteredParts}
groupBy={(option: Part) =>
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) => (
<TextField
{...params}
size="small"
sx={{ width: "100%" }}
label="Parts"
placeholder="Begin typing to search"
/>
)}
/>
);
}}
/>
);
}}
/>
</Grid>
<Grid item>
<Button
variant="outlined"
onClick={handleToggleParts}
disabled={partsQuery.isFetching}
sx={{
whiteSpace: "nowrap",
height: hasSelectedParts ? 50 : 40,
}}
>
{hasSelectedParts ? "Deselect All" : "Select All"}
</Button>
</Grid>
</Grid>
)}
</Grid>
<Grid item xs={12} sx={{ display: "flex", alignItems: "center" }}>
<Controller
Expand Down
Loading