diff --git a/api/models/part.py b/api/models/part.py index dfa5144d..4d8e9915 100644 --- a/api/models/part.py +++ b/api/models/part.py @@ -1,7 +1,7 @@ -from datetime import date +from datetime import datetime from typing import List, Optional -from sqlalchemy import Boolean, Column, Date, Float, ForeignKey, Integer, String, Table +from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Table from sqlalchemy.orm import Mapped, mapped_column, relationship from api.models.base import Base @@ -50,14 +50,16 @@ 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 + meter_activity_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("MeterActivities.id"), nullable=True ) part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + note: Mapped[Optional[str]] = mapped_column(String, nullable=True) part: Mapped["Parts"] = relationship(back_populates="parts_used_links") - meter_activity: Mapped["MeterActivities"] = relationship( + meter_activity: Mapped[Optional["MeterActivities"]] = relationship( back_populates="parts_used_links" ) @@ -68,7 +70,7 @@ class PartsAdded(Base): 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) + date: Mapped[datetime] = mapped_column(DateTime, nullable=False) note: Mapped[str | None] = mapped_column(String, nullable=True) part: Mapped["Parts"] = relationship() diff --git a/api/routes/meters.py b/api/routes/meters.py index 1f0b8ac4..9cd09a1b 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -111,9 +111,7 @@ def sort_by_field_to_schema_field(name: MeterSortByField): dependencies=[Depends(ScopedUser.Admin)], tags=["Meters"], ) -def create_meter( - new_meter: meter.SubmitNewMeter, db: Session = Depends(get_db) -): +def create_meter(new_meter: meter.SubmitNewMeter, db: Session = Depends(get_db)): """ Create a new meter. This requires a SN and meter type. Status is infered from based on if a well is provided. @@ -131,6 +129,7 @@ def create_meter( serial_number=new_meter.serial_number, contact_name=new_meter.contact_name, contact_phone=new_meter.contact_phone, + notes=new_meter.notes, meter_type_id=new_meter.meter_type.id, price=new_meter.price, status_id=warehouse_status_id, @@ -250,9 +249,7 @@ def get_meters_locations( meter_pm_query, {"mids": meter_ids, "pm_activity_type_id": pm_activity_type_id}, ).fetchall() - meter_pm_dict = { - row.meter_id: row.last_pm_meter_activity for row in meter_pm_rows - } + meter_pm_dict = {row.meter_id: row.last_pm_meter_activity for row in meter_pm_rows} location_only_dict = {} @@ -404,9 +401,7 @@ def update_meter_type( dependencies=[Depends(ScopedUser.Admin)], tags=["Meters"], ) -def create_meter_type( - new_meter_type: meter.MeterTypeLU, db: Session = Depends(get_db) -): +def create_meter_type(new_meter_type: meter.MeterTypeLU, db: Session = Depends(get_db)): new_type_model = MeterTypeLU( brand=new_meter_type.brand, series=new_meter_type.series, @@ -441,9 +436,7 @@ def get_land_owners( response_model=meter.Meter, tags=["Meters"], ) -def patch_meter( - updated_meter: meter.SubmitMeterUpdate, db: Session = Depends(get_db) -): +def patch_meter(updated_meter: meter.SubmitMeterUpdate, db: Session = Depends(get_db)): """ Update the current state of a meter. This is only used by Meter Details on the frontend. diff --git a/api/routes/parts.py b/api/routes/parts.py index 7afd3ba5..980be21c 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -202,6 +202,18 @@ def add_parts(payload: parts.PartsAddRequest, db: Session = Depends(get_db)): return part_service.add_parts(db, payload) +@part_router.post( + "/parts/decrease", + response_model=parts.Part, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def decrease_parts( + payload: parts.PartsDecreaseRequest, db: Session = Depends(get_db) +): + return part_service.decrease_parts(db, payload) + + @part_router.get( "/parts/{part_id}/history", response_model=parts.PartHistoryResponse, diff --git a/api/schemas/parts.py b/api/schemas/parts.py index 767819ad..c16f3b0b 100644 --- a/api/schemas/parts.py +++ b/api/schemas/parts.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import datetime from typing import List, Literal, Optional from api.schemas.base import ORMBase @@ -55,7 +55,14 @@ class PartUsed(ORMBase): class PartsAddRequest(ORMBase): part_id: int count: int - date: date + date: datetime + note: Optional[str] = None + + +class PartsDecreaseRequest(ORMBase): + part_id: int + count: int + date: datetime note: Optional[str] = None @@ -63,9 +70,10 @@ class PartHistoryRow(ORMBase): row_id: str part_id: int event_date: datetime - event_type: Literal["initial", "added", "used"] + event_type: Literal["initial", "added", "used", "workorder"] ref_id: int | None = None work_order_id: int | None = None + meter_activity_type: str | None = None note: str | None = None delta: int total_after: int @@ -74,7 +82,7 @@ class PartHistoryRow(ORMBase): class PartHistoryUpdateRow(ORMBase): ref_id: int event_date: datetime - event_type: Literal["added", "used"] + event_type: Literal["added", "used", "workorder"] note: str | None = None delta: int diff --git a/api/services/parts.py b/api/services/parts.py index fddccfc4..039805c5 100644 --- a/api/services/parts.py +++ b/api/services/parts.py @@ -1,15 +1,15 @@ -from datetime import date, datetime, time +from datetime import date, datetime from io import BytesIO from pathlib import Path from typing import Optional from fastapi import HTTPException from jinja2 import Environment, FileSystemLoader, select_autoescape -from sqlalchemy import func, literal, select, union_all +from sqlalchemy import case, func, literal, select, union_all from sqlalchemy.orm import Session, selectinload from weasyprint import HTML -from api.models.meter import MeterActivities, meterRegisters +from api.models.meter import ActivityTypeLU, MeterActivities, meterRegisters from api.models.part import Parts, PartsAdded, PartsUsed from api.schemas import parts @@ -46,9 +46,7 @@ def _part_count_subqueries(): return used_subq, added_subq, current_count -def build_part_history_response( - part_id: int, db: Session -) -> parts.PartHistoryResponse: +def build_part_history_response(part_id: int, db: Session) -> parts.PartHistoryResponse: part = db.scalars(select(Parts).where(Parts.id == part_id)).first() if not part: raise HTTPException(status_code=404, detail="Part not found") @@ -61,19 +59,32 @@ def build_part_history_response( PartsAdded.note.label("note"), PartsAdded.count.label("delta"), literal(None).label("work_order_id"), + literal(None).label("meter_activity_type"), ).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"), + func.coalesce( + PartsUsed.date, + MeterActivities.timestamp_start, + func.now(), + ).label("event_date"), + case( + (MeterActivities.work_order_id.is_not(None), literal("workorder")), + else_=literal("used"), + ).label("event_type"), + func.coalesce( + func.nullif(func.trim(PartsUsed.note), ""), + func.nullif(func.trim(MeterActivities.description), ""), + ).label("note"), (-PartsUsed.count).label("delta"), MeterActivities.work_order_id.label("work_order_id"), + ActivityTypeLU.name.label("meter_activity_type"), ) - .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .outerjoin(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .outerjoin(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id) .where(PartsUsed.part_id == part_id) ) @@ -87,6 +98,7 @@ def build_part_history_response( events.c.note, events.c.delta, events.c.work_order_id, + events.c.meter_activity_type, ).order_by(events.c.event_date.asc(), events.c.ref_id.asc()) ).all() @@ -102,12 +114,20 @@ def build_part_history_response( delta=0, total_after=running, work_order_id=None, + meter_activity_type=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) + for ( + ref_id, + pid, + event_date, + event_type, + note, + delta, + work_order_id, + meter_activity_type, + ) in rows: running += int(delta) history.append( parts.PartHistoryRow( @@ -120,6 +140,7 @@ def build_part_history_response( delta=int(delta), total_after=running, work_order_id=work_order_id, + meter_activity_type=meter_activity_type, ) ) @@ -150,7 +171,9 @@ def list_parts(db: Session, in_use: Optional[bool] = None): return results -def get_parts_used_summary(db: Session, from_date: date, to_date: date, parts: list[int]): +def get_parts_used_summary( + db: Session, from_date: date, to_date: date, parts: list[int] +): start_dt = datetime.combine(from_date, datetime.min.time()) end_dt = datetime.combine(to_date, datetime.max.time()) usage_subq = ( @@ -236,8 +259,8 @@ def get_part(db: Session, part_id: int): ).first() register_details_obj = None if register_details is not None: - register_details_obj = ( - parts.Register.register_details.model_validate(register_details) + register_details_obj = parts.Register.register_details.model_validate( + register_details ) returned_part = parts.Register( **returned_part.model_dump(exclude_unset=True), @@ -276,6 +299,37 @@ def add_parts(db: Session, payload: parts.PartsAddRequest): return part_obj +def decrease_parts(db: Session, payload: parts.PartsDecreaseRequest): + part = db.scalars(select(Parts).where(Parts.id == payload.part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + db.add( + PartsUsed( + part_id=payload.part_id, + count=payload.count, + date=payload.date, + note=payload.note, + meter_activity_id=None, + ) + ) + db.commit() + + used_subq, added_subq, current_count = _part_count_subqueries() + 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 + + def patch_part_history( db: Session, part_id: int, payload: parts.PartHistoryUpdateRequest ): @@ -301,9 +355,11 @@ def patch_part_history( ) ).first() if not added_row: - raise HTTPException(status_code=404, detail="Parts added row not found.") + 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.date = row.event_date added_row.note = normalized_note continue @@ -320,6 +376,13 @@ def patch_part_history( ).first() if not parts_used_row: raise HTTPException(status_code=404, detail="Parts used row not found.") + parts_used_row.count = abs(row.delta) + parts_used_row.note = normalized_note + + if parts_used_row.meter_activity_id is None: + parts_used_row.date = row.event_date + continue + activity = db.scalars( select(MeterActivities).where( MeterActivities.id == parts_used_row.meter_activity_id @@ -335,9 +398,7 @@ def patch_part_history( if activity.timestamp_end and activity.timestamp_start else None ) - parts_used_row.count = abs(row.delta) activity.timestamp_start = row.event_date - activity.description = normalized_note activity.timestamp_end = ( row.event_date + duration if duration is not None else row.event_date ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e6f7797..67879093 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4764,9 +4764,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8633,9 +8633,9 @@ "license": "MIT" }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, "node_modules/punycode": { @@ -10227,9 +10227,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/src/components/Modals/Parts/DecreaseQuantity.tsx b/frontend/src/components/Modals/Parts/DecreaseQuantity.tsx new file mode 100644 index 00000000..6f29d16e --- /dev/null +++ b/frontend/src/components/Modals/Parts/DecreaseQuantity.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { DateTimePicker } from "@mui/x-date-pickers"; +import dayjs, { Dayjs } from "dayjs"; +import { DecreaseQuantityPayload, Part } from "@/interfaces"; +import { Save } from "@mui/icons-material"; + +export const DecreaseQuantityModal = ({ + open, + onClose, + parts, + defaultPartId, + onSubmit, + title = "Decrease Part Quantity", + loading, +}: { + open: boolean; + onClose: () => void; + parts: Part[]; + defaultPartId?: number | string; + onSubmit: (payload: DecreaseQuantityPayload) => 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 [decreaseBy, setDecreaseBy] = useState("1"); + const [date, setDate] = useState(dayjs()); + const [note, setNote] = useState(""); + + const decreaseByNum = Number(decreaseBy); + const partError = !selectedPart; + const qtyError = + decreaseBy.trim().length === 0 || + Number.isNaN(decreaseByNum) || + !Number.isFinite(decreaseByNum) || + decreaseByNum <= 0; + const dateError = !date; + + useEffect(() => { + if (!open) { + return; + } + + setDate(dayjs()); + setDecreaseBy("1"); + setNote(""); + + if (defaultPartId !== undefined) { + setSelectedPart(partsById.get(defaultPartId) ?? null); + } else { + setSelectedPart(null); + } + }, [open, defaultPartId, partsById]); + + const handleSubmit = () => { + if (!selectedPart || qtyError || !date) { + return; + } + + onSubmit({ + part_id: selectedPart.id, + count: Math.trunc(decreaseByNum), + date: date.format("YYYY-MM-DDTHH:mm:ss"), + note: note.trim().length ? note.trim() : undefined, + }); + }; + + return ( + + {title} + + + + + Select a part, enter how many to remove, and choose the date for the + parts used record. + + + setSelectedPart(value)} + getOptionLabel={(option) => + option?.part_number + ? `${option.part_number} — ${option.description ?? ""}` + : (option?.description ?? "") + } + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + + setDecreaseBy(e.target.value)} + inputProps={{ min: 1, step: 1 }} + error={qtyError} + helperText={qtyError ? "Enter a number greater than 0." : " "} + /> + + setDate(newDate)} + disableFuture + format="MMM D, YYYY h:mm A" + slotProps={{ + textField: { + helperText: dateError ? "Date and time are required." : "Required.", + error: dateError, + fullWidth: true, + size: "small", + }, + }} + /> + + setNote(e.target.value)} + placeholder="Optional inventory note" + multiline + minRows={2} + maxRows={4} + /> + + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx index 88283fed..c2a89c6f 100644 --- a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx +++ b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx @@ -10,7 +10,7 @@ import { TextField, Typography, } from "@mui/material"; -import { DatePicker } from "@mui/x-date-pickers"; +import { DateTimePicker } from "@mui/x-date-pickers"; import dayjs, { Dayjs } from "dayjs"; import { Part } from "@/interfaces"; import { IncreaseQuantityPayload } from "@/interfaces/IncreaseQuantityPayload"; @@ -75,7 +75,7 @@ export const IncreaseQuantityModal = ({ onSubmit({ part_id: selectedPart.id, count: Math.trunc(increaseByNum), - date: date?.format("YYYY-MM-DD"), + date: date?.format("YYYY-MM-DDTHH:mm:ss"), note: note.trim().length ? note.trim() : undefined, }); }; @@ -130,14 +130,15 @@ export const IncreaseQuantityModal = ({ helperText={qtyError ? "Enter a number greater than 0." : " "} /> - setDate(newDate)} disableFuture + format="MMM D, YYYY h:mm A" slotProps={{ textField: { - helperText: "Defaults to today.", + helperText: "Defaults to now.", fullWidth: true, size: "small", }, diff --git a/frontend/src/components/Modals/Parts/index.ts b/frontend/src/components/Modals/Parts/index.ts index fad3d59b..417a9b7c 100644 --- a/frontend/src/components/Modals/Parts/index.ts +++ b/frontend/src/components/Modals/Parts/index.ts @@ -1 +1,2 @@ +export * from "./DecreaseQuantity"; export * from "./IncreaseQuantity"; diff --git a/frontend/src/components/display/EventTypeChip.tsx b/frontend/src/components/display/EventTypeChip.tsx index e9d63d8d..51f2d9ec 100644 --- a/frontend/src/components/display/EventTypeChip.tsx +++ b/frontend/src/components/display/EventTypeChip.tsx @@ -1,9 +1,30 @@ -import { Chip } from "@mui/material"; +import { Box, Chip } from "@mui/material"; + +const METER_ACTIVITY_TYPE_LABELS: Record = { + Install: "Install", + Uninstall: "Uninstall", + "Preventative Maintenance": "PM", + Repair: "Repair", + "Rate Meter": "Rate Meter", + Sell: "Sell", + Scrap: "Scrap", + "Location Only": "Location Only", + "Change Water Users": "Water Users", + "Re-install": "Re-install", + "Uninstall and Hold": "Uninstall + Hold", +}; + +const getShortActivityLabel = (type?: string | null): string | null => { + if (!type) return null; + return METER_ACTIVITY_TYPE_LABELS[type] ?? type; +}; export const EventTypeChip = ({ event_type, + meter_activity_type, }: { - event_type: "added" | "used" | "initial" | "current" | string; + event_type: "added" | "used" | "workorder" | "initial" | "current" | string; + meter_activity_type?: string | null; }) => { switch (event_type) { case "added": { @@ -18,12 +39,60 @@ export const EventTypeChip = ({ } case "used": { return ( - + + + + + ); + } + case "workorder": { + return ( + + + {meter_activity_type && ( + + )} + ); } case "initial": { diff --git a/frontend/src/interfaces/DecreaseQuantityPayload.ts b/frontend/src/interfaces/DecreaseQuantityPayload.ts new file mode 100644 index 00000000..d90837cf --- /dev/null +++ b/frontend/src/interfaces/DecreaseQuantityPayload.ts @@ -0,0 +1,6 @@ +export interface DecreaseQuantityPayload { + part_id: number | string; + count: number; + date: string | undefined; + note?: string; +} diff --git a/frontend/src/interfaces/IncreaseQuantityPayload.ts b/frontend/src/interfaces/IncreaseQuantityPayload.ts index 46089841..0ebe0ffe 100644 --- a/frontend/src/interfaces/IncreaseQuantityPayload.ts +++ b/frontend/src/interfaces/IncreaseQuantityPayload.ts @@ -1,6 +1,6 @@ export interface IncreaseQuantityPayload { part_id: number | string; count: number; - date: string | undefined; // YYYY-MM-DD + date: string | undefined; note?: string; } diff --git a/frontend/src/interfaces/PartHistoryResponse.ts b/frontend/src/interfaces/PartHistoryResponse.ts index 1e9a4761..22c8e3b8 100644 --- a/frontend/src/interfaces/PartHistoryResponse.ts +++ b/frontend/src/interfaces/PartHistoryResponse.ts @@ -2,9 +2,10 @@ export type PartHistoryRow = { row_id: string; part_id: number; event_date: string; - event_type: "initial" | "added" | "used"; + event_type: "initial" | "added" | "used" | "workorder"; ref_id?: number | null; work_order_id?: number | null; + meter_activity_type?: string | null; note?: string | null; delta: number; total_after: number; @@ -13,7 +14,7 @@ export type PartHistoryRow = { export type EditablePartHistoryRow = { ref_id: number; event_date: string; - event_type: "added" | "used"; + event_type: "added" | "used" | "workorder"; note?: string | null; delta: number; }; diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 1ac00737..97d715f4 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -5,6 +5,7 @@ export * from "./BackupRow"; export * from "./BaseWell"; export * from "./CreateUser"; export * from "./CreateNotificationPayload"; +export * from "./DecreaseQuantityPayload"; export * from "./DeviceAttributes"; export * from "./DevicePayload"; export * from "./HomeSummary"; diff --git a/frontend/src/routes/manage/parts/$id/history.tsx b/frontend/src/routes/manage/parts/$id/history.tsx index f440955b..4b9e3de7 100644 --- a/frontend/src/routes/manage/parts/$id/history.tsx +++ b/frontend/src/routes/manage/parts/$id/history.tsx @@ -4,44 +4,49 @@ import dayjs from "dayjs"; import { PartsHistory } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; import { API_URL } from "@/config"; -import { - dayjsDateParam, - pageParam, -} from "@/utils"; +import { dayjsDateParam, pageParam } from "@/utils"; import { PartHistoryResponse } from "@/interfaces/PartHistoryResponse"; -const eventTypeValues = ["initial", "used", "added", "current"] as const; +const eventTypeValues = [ + "initial", + "used", + "added", + "workorder", + "current", +] as const; const defaultEventTypes: [ (typeof eventTypeValues)[number], ...(typeof eventTypeValues)[number][], -] = ["initial", "used", "added", "current"]; +] = ["initial", "used", "added", "workorder", "current"]; const eventTypesSchema = z - .preprocess((val) => { - if (val == null || val === "") return undefined; + .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 + 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] => + 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()) + const set = new Set(values); + return eventTypeValues.filter((type) => set.has(type)); + }, + z.array(z.enum(eventTypeValues)).nonempty().optional(), + ) .catch(defaultEventTypes) .default(defaultEventTypes); diff --git a/frontend/src/service/meters.ts b/frontend/src/service/meters.ts index 2889d898..513ff5a5 100644 --- a/frontend/src/service/meters.ts +++ b/frontend/src/service/meters.ts @@ -112,13 +112,20 @@ export function useCreateMeter(onSuccess: Function) { enqueueSnackbar("Unknown Error Occurred!", { variant: "error" }); throw Error("Unknown Error: " + response.status); } - } else { - onSuccess(); - - const responseJson = await response.json(); - invalidateMapDataCaches(queryClient); - return responseJson; } + + return response.json(); + }, + onSuccess: (responseJson) => { + onSuccess(); + + invalidateMapDataCaches(queryClient); + + queryClient.invalidateQueries({ + queryKey: [route], + }); + + return responseJson; }, retry: 0, }); diff --git a/frontend/src/service/parts.ts b/frontend/src/service/parts.ts index f7728a71..a87550ef 100644 --- a/frontend/src/service/parts.ts +++ b/frontend/src/service/parts.ts @@ -1,7 +1,7 @@ import { useSnackbar } from "notistack"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useApiClient } from "@/hooks"; -import { IncreaseQuantityPayload } from "@/interfaces"; +import { DecreaseQuantityPayload, IncreaseQuantityPayload } from "@/interfaces"; import { MeterTypeLU, Part } from "@/interfaces"; import { PartHistoryResponse, @@ -104,6 +104,10 @@ export function useCreateMeterType(onSuccess: Function) { } else { onSuccess(); + queryClient.invalidateQueries({ + queryKey: [route], + }); + const responseJson = await response.json(); queryClient.setQueryData( ["meter_types"], @@ -211,6 +215,10 @@ export function useCreatePart(onSuccess: Function) { } else { onSuccess(); + queryClient.invalidateQueries({ + queryKey: [route], + }); + const responseJson = await response.json(); queryClient.setQueryData(["parts"], (old: Part[] | undefined) => { if (old != undefined) { @@ -284,6 +292,56 @@ export function useAddParts(onSuccess?: () => void) { }); } +export function useDecreaseParts(onSuccess?: () => void) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + const route = "parts/decrease"; + + return useMutation({ + mutationFn: async (payload: DecreaseQuantityPayload) => { + const response = await apiClient.post(route, payload); + + 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)"); + } + + 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(); + + 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 apiClient = useApiClient(); @@ -308,7 +366,10 @@ export function useUpdatePartHistory( throw new Error("Missing part id"); } - const response = await apiClient.patch(`parts/${partId}/history`, payload); + const response = await apiClient.patch( + `parts/${partId}/history`, + payload, + ); if (!response.ok) { let detail = ""; diff --git a/frontend/src/service/users.ts b/frontend/src/service/users.ts index 2994a0e3..6cc9f0fc 100644 --- a/frontend/src/service/users.ts +++ b/frontend/src/service/users.ts @@ -1,13 +1,27 @@ import { useSnackbar } from "notistack"; -import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "react-query"; +import { + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "react-query"; import { useApiClient } from "@/hooks"; -import { AuthTokenResponse, UpdatedUserPassword, User, UserRole } from "@/interfaces"; +import { + AuthTokenResponse, + UpdatedUserPassword, + User, + UserRole, +} from "@/interfaces"; export function useGetRoles(options?: UseQueryOptions) { const apiClient = useApiClient(); const route = "roles"; - return useQuery([route], () => apiClient.get(route), options); + return useQuery( + [route], + () => apiClient.get(route), + options, + ); } export function useGetUserAdminList(options?: UseQueryOptions) { @@ -40,7 +54,10 @@ export function useImpersonateUser() { return useMutation({ mutationFn: async (userId: number) => { - const response = await apiClient.post(`users/${userId}/impersonate`, undefined); + const response = await apiClient.post( + `users/${userId}/impersonate`, + undefined, + ); if (!response.ok) { throw new Error(await response.text()); } @@ -74,6 +91,10 @@ export function useCreateUser(onSuccess: Function) { } else { onSuccess(); + queryClient.invalidateQueries({ + queryKey: [route], + }); + const responseJson = await response.json(); queryClient.setQueryData(["usersadmin"], (old: User[] | undefined) => { if (old != undefined) { diff --git a/frontend/src/service/workOrders.ts b/frontend/src/service/workOrders.ts index 4f5ce226..699e22f8 100644 --- a/frontend/src/service/workOrders.ts +++ b/frontend/src/service/workOrders.ts @@ -1,5 +1,10 @@ import { useSnackbar } from "notistack"; -import { useMutation, useQuery, UseQueryOptions } from "react-query"; +import { + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "react-query"; import { useApiClient } from "@/hooks"; import { NewWorkOrder, PatchWorkOrder, WorkOrder } from "@/interfaces"; import { WorkOrderStatus } from "@/enums"; @@ -91,6 +96,7 @@ export function useDeleteWorkOrder(onSuccess: Function) { export function useCreateWorkOrder() { const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); const apiClient = useApiClient(); const route = "work_orders"; @@ -115,6 +121,10 @@ export function useCreateWorkOrder() { throw Error("Unknown Error: " + response.status); } } else { + queryClient.invalidateQueries({ + queryKey: [route], + }); + const responseJson = await response.json(); return responseJson; } diff --git a/frontend/src/views/Parts/PartsHistory.tsx b/frontend/src/views/Parts/PartsHistory.tsx index bb6f79e5..4918690d 100644 --- a/frontend/src/views/Parts/PartsHistory.tsx +++ b/frontend/src/views/Parts/PartsHistory.tsx @@ -25,6 +25,7 @@ import { InfoOutlined, NavigateNext, PlusOne, + Remove, Save, Search, } from "@mui/icons-material"; @@ -43,10 +44,12 @@ import { EventTypeChip, ControlledDatepicker, ControlledSelectNonObject, + DecreaseQuantityModal, IncreaseQuantityModal, } from "@/components"; import { useAddParts, + useDecreaseParts, useGetPartHistory, useGetParts, useUpdatePartHistory, @@ -60,8 +63,14 @@ import { } from "@/interfaces/PartHistoryResponse"; import { useSnackbar } from "notistack"; -type EventType = "initial" | "used" | "added" | "current"; -const EVENT_TYPE_ORDER: EventType[] = ["initial", "used", "added", "current"]; +type EventType = "initial" | "used" | "added" | "workorder" | "current"; +const EVENT_TYPE_ORDER: EventType[] = [ + "initial", + "used", + "added", + "workorder", + "current", +]; type PartsHistoryFormValues = { from?: Dayjs | null; @@ -81,7 +90,12 @@ const schema = yup.object().shape({ }), event_types: yup .array() - .of(yup.string().oneOf(["initial", "used", "added", "current"]).required()) + .of( + yup + .string() + .oneOf(["initial", "used", "added", "workorder", "current"]) + .required(), + ) .min(1, "Select at least one event type") .required(), }); @@ -106,6 +120,17 @@ function sameStringArray(a: string[], b: string[]) { return a.length === b.length && a.every((value, index) => value === b[index]); } +function serializePartTimestamp(value: Dayjs) { + return value.format("YYYY-MM-DDTHH:mm:ss"); +} + +function formatPartTimestamp(value: unknown) { + if (!value) return "-"; + + const parsed = dayjs(String(value)); + return parsed.isValid() ? parsed.format("MMM D, YYYY h:mm A") : String(value); +} + function recalculateRows(sourceRows: any[]) { const initialRow = sourceRows.find((row) => row.event_type === "initial"); const currentRow = sourceRows.find((row) => row.event_type === "current"); @@ -142,7 +167,7 @@ function hydrateRows(data: PartHistoryResponse, partId?: string) { ? { row_id: `current-${partId ?? "unknown"}`, part_id: Number(partId), - event_date: dayjs().toISOString(), + event_date: serializePartTimestamp(dayjs()), event_type: "current", ref_id: null, note: "Current count", @@ -294,6 +319,7 @@ export const PartsHistory = () => { const history = useGetPartHistory(id); const partsList = useGetParts(); const addParts = useAddParts(); + const decreaseParts = useDecreaseParts(); const { enqueueSnackbar } = useSnackbar(); const updateHistory = useUpdatePartHistory(id, (response) => { const nextRows = hydrateRows(response, id); @@ -306,6 +332,7 @@ export const PartsHistory = () => { const [originalRows, setOriginalRows] = useState([]); const [hasChanges, setHasChanges] = useState(false); const [increaseOpen, setIncreaseOpen] = useState(false); + const [decreaseOpen, setDecreaseOpen] = useState(false); const [snackbar, setSnackbar] = useState<{ message: string; severity: "success" | "error"; @@ -451,7 +478,10 @@ export const PartsHistory = () => { try { const changedRows = rows .filter( - (row) => row.event_type === "added" || row.event_type === "used", + (row) => + row.event_type === "added" || + row.event_type === "used" || + row.event_type === "workorder", ) .filter((row) => { const originalRow = originalRows.find( @@ -470,7 +500,7 @@ export const PartsHistory = () => { (row): EditablePartHistoryRow => ({ ref_id: Number(row.ref_id), event_type: row.event_type, - event_date: dayjs(row.event_date).toISOString(), + event_date: serializePartTimestamp(dayjs(row.event_date)), note: row.note ?? null, delta: Number(row.delta), }), @@ -506,13 +536,8 @@ export const PartsHistory = () => { 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"); + if (row.event_type === "current") return formatPartTimestamp(dayjs()); + return formatPartTimestamp(params.value); }, renderEditCell: (params) => { const { id, value, api } = params; @@ -525,7 +550,7 @@ export const PartsHistory = () => { api.setEditCellValue({ id, field: "event_date", - value: newValue.toISOString(), + value: serializePartTimestamp(newValue), }); } }} @@ -551,9 +576,12 @@ export const PartsHistory = () => { { field: "event_type", headerName: "Type", - width: 140, + width: 260, renderCell: (params) => ( - + ), }, { @@ -680,16 +708,23 @@ export const PartsHistory = () => { control={control} name="event_types" multiple - options={["initial", "used", "added", "current"]} - getOptionLabel={(opt: EventType) => - opt === "used" - ? "Work Orders" - : opt === "added" - ? "Parts Added" - : opt === "current" - ? "Current" - : "Initial" - } + options={["initial", "used", "added", "workorder", "current"]} + getOptionLabel={(opt: EventType) => { + switch (opt) { + case "initial": + return "Initial"; + case "added": + return "Parts Added"; + case "used": + return "Parts Used"; + case "workorder": + return "Work Orders"; + case "current": + return "Current"; + default: + return opt; + } + }} /> @@ -819,21 +854,43 @@ export const PartsHistory = () => { > Reset - + + + @@ -874,6 +931,32 @@ export const PartsHistory = () => { }); }} /> + setDecreaseOpen(false)} + parts={partsList.data ?? []} + defaultPartId={id ? Number(id) : undefined} + loading={decreaseParts.isLoading} + onSubmit={(payload) => { + decreaseParts.mutate(payload, { + onSuccess: async () => { + enqueueSnackbar("Quantity decrease submitted successfully.", { + variant: "success", + }); + setDecreaseOpen(false); + await Promise.all([partsList.refetch(), history.refetch()]); + }, + onError: () => { + enqueueSnackbar( + "Failed to submit quantity decrease. Please try again.", + { + variant: "error", + }, + ); + }, + }); + }} + /> ); }; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index a470a848..15461dc6 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -13,13 +13,7 @@ import { TextField, Typography, } from "@mui/material"; -import { - PlusOne, - Search, - Add, - History, - Build, -} from "@mui/icons-material"; +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"; @@ -85,7 +79,7 @@ export const PartsTable = ({ params={{ id: String(params.row.id) }} search={{ to: dayjs().endOf("month").format("YYYY-MM-DD"), - type: ["initial", "used", "added", "current"], + type: ["initial", "used", "added", "workorder", "current"], q: "", page: 0, pageSize: 25, @@ -233,8 +227,7 @@ export const PartsTable = ({ setSearch((prev) => ({ ...prev, p_pageSize: model.pageSize, - p_page: - model.pageSize !== prev.p_pageSize ? 0 : model.page, + p_page: model.pageSize !== prev.p_pageSize ? 0 : model.page, })) } pageSizeOptions={[10, 25, 50, 100]} diff --git a/migrations/20260422043632_update_parts_used_add_notes_and_make_meter_activity_id_optional.down.sql b/migrations/20260422043632_update_parts_used_add_notes_and_make_meter_activity_id_optional.down.sql new file mode 100644 index 00000000..03567362 --- /dev/null +++ b/migrations/20260422043632_update_parts_used_add_notes_and_make_meter_activity_id_optional.down.sql @@ -0,0 +1,19 @@ +-- Fail if any NULL values exist +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public."PartsUsed" + WHERE meter_activity_id IS NULL + ) THEN + RAISE EXCEPTION 'Cannot revert migration: meter_activity_id contains NULL values'; + END IF; +END $$; + +ALTER TABLE public."PartsUsed" +DROP COLUMN note, +DROP COLUMN date; + +-- Restore NOT NULL constraint +ALTER TABLE public."PartsUsed" +ALTER COLUMN meter_activity_id SET NOT NULL; diff --git a/migrations/20260422043632_update_parts_used_add_notes_and_make_meter_activity_id_optional.up.sql b/migrations/20260422043632_update_parts_used_add_notes_and_make_meter_activity_id_optional.up.sql new file mode 100644 index 00000000..f4f91e3e --- /dev/null +++ b/migrations/20260422043632_update_parts_used_add_notes_and_make_meter_activity_id_optional.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE public."PartsUsed" +ADD COLUMN note varchar NULL, +ADD COLUMN date date NULL; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN meter_activity_id DROP NOT NULL; diff --git a/migrations/20260422135731_update_parts_used_and_parts_added_tables_from_date_to_datetime.down.sql b/migrations/20260422135731_update_parts_used_and_parts_added_tables_from_date_to_datetime.down.sql new file mode 100644 index 00000000..affb9344 --- /dev/null +++ b/migrations/20260422135731_update_parts_used_and_parts_added_tables_from_date_to_datetime.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE public."PartsAdded" +ALTER COLUMN "date" TYPE date +USING "date"::date; + +-- Restore the old default +ALTER TABLE public."PartsAdded" +ALTER COLUMN "date" SET DEFAULT CURRENT_DATE; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN "date" TYPE date +USING "date"::date; diff --git a/migrations/20260422135731_update_parts_used_and_parts_added_tables_from_date_to_datetime.up.sql b/migrations/20260422135731_update_parts_used_and_parts_added_tables_from_date_to_datetime.up.sql new file mode 100644 index 00000000..f3639cbe --- /dev/null +++ b/migrations/20260422135731_update_parts_used_and_parts_added_tables_from_date_to_datetime.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE public."PartsAdded" +ALTER COLUMN "date" TYPE timestamp without time zone +USING "date"::timestamp without time zone; + +-- Keep the default behavior aligned with the new type +ALTER TABLE public."PartsAdded" +ALTER COLUMN "date" SET DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN "date" TYPE timestamp without time zone +USING "date"::timestamp without time zone;