From 77e128af5ae0defac8b200c68ecf2185b9f86be2 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 12 Jun 2026 11:54:42 -0300 Subject: [PATCH] feat: add PM schedules CRUD module --- .../_components/pm-schedule-form.tsx | 185 +++++++++++++++ src/app/(protected)/pmschedules/edit/[id].tsx | 1 + src/app/(protected)/pmschedules/index.tsx | 216 ++++++++++++++++++ src/app/(protected)/pmschedules/new.tsx | 1 + .../pm-schedules/api/pm-schedules-api.ts | 57 +++++ .../mappers/pm-schedule-mapper.ts | 107 +++++++++ .../schemas/pm-schedule-schema.ts | 14 ++ src/domain/pm-schedules/types/pm-schedule.ts | 37 +++ .../use-cases/use-delete-pm-schedule.ts | 19 ++ .../use-cases/use-pm-schedule-detail.ts | 13 ++ .../use-cases/use-pm-schedules-list.ts | 17 ++ .../use-cases/use-save-pm-schedule.ts | 37 +++ 12 files changed, 704 insertions(+) create mode 100644 src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx create mode 100644 src/app/(protected)/pmschedules/edit/[id].tsx create mode 100644 src/app/(protected)/pmschedules/index.tsx create mode 100644 src/app/(protected)/pmschedules/new.tsx create mode 100644 src/domain/pm-schedules/api/pm-schedules-api.ts create mode 100644 src/domain/pm-schedules/mappers/pm-schedule-mapper.ts create mode 100644 src/domain/pm-schedules/schemas/pm-schedule-schema.ts create mode 100644 src/domain/pm-schedules/types/pm-schedule.ts create mode 100644 src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts create mode 100644 src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts create mode 100644 src/domain/pm-schedules/use-cases/use-pm-schedules-list.ts create mode 100644 src/domain/pm-schedules/use-cases/use-save-pm-schedule.ts diff --git a/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx new file mode 100644 index 00000000..aaed0b51 --- /dev/null +++ b/src/app/(protected)/pmschedules/_components/pm-schedule-form.tsx @@ -0,0 +1,185 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Box, + Button, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { + pmScheduleSchema, + type PmScheduleFormValues, +} from "@/domain/pm-schedules/schemas/pm-schedule-schema"; +import { usePmScheduleDetail } from "@/domain/pm-schedules/use-cases/use-pm-schedule-detail"; +import { useSavePmSchedule } from "@/domain/pm-schedules/use-cases/use-save-pm-schedule"; +import { toDateInputValue } from "@/lib/time-utils"; + +const defaultValues: PmScheduleFormValues = { + asset: "", + assetName: "", + frequency: "Quarterly", + lastServiceDate: "", + nextServiceDate: "", + status: "Active", + assignedTo: "", + notes: "", +}; + +export default function PmScheduleFormPage() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = Boolean(id); + + const { data: schedule, isLoading, error } = usePmScheduleDetail(id); + const savePmSchedule = useSavePmSchedule(id); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(pmScheduleSchema), + defaultValues, + }); + + useEffect(() => { + if (schedule && isEdit) { + reset({ + asset: schedule.asset, + assetName: schedule.assetName, + frequency: schedule.frequency as PmScheduleFormValues["frequency"], + lastServiceDate: toDateInputValue(schedule.lastServiceDate), + nextServiceDate: toDateInputValue(schedule.nextServiceDate), + status: schedule.status as PmScheduleFormValues["status"], + assignedTo: schedule.assignedTo, + notes: schedule.notes, + }); + } + }, [schedule, isEdit, reset]); + + if (isEdit && isLoading) { + return ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit PM Schedule" : "Create PM Schedule"} + + {error && Failed to load PM schedule} + + savePmSchedule.mutate(values))}> + + + + + + + ( + + Frequency + + + )} + /> + + + + + + + + + ( + + Status + + + )} + /> + + + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/pmschedules/edit/[id].tsx b/src/app/(protected)/pmschedules/edit/[id].tsx new file mode 100644 index 00000000..d6a04793 --- /dev/null +++ b/src/app/(protected)/pmschedules/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/pm-schedule-form"; diff --git a/src/app/(protected)/pmschedules/index.tsx b/src/app/(protected)/pmschedules/index.tsx new file mode 100644 index 00000000..92874c84 --- /dev/null +++ b/src/app/(protected)/pmschedules/index.tsx @@ -0,0 +1,216 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import type { PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule"; +import { useDeletePmSchedule } from "@/domain/pm-schedules/use-cases/use-delete-pm-schedule"; +import { usePmSchedulesList } from "@/domain/pm-schedules/use-cases/use-pm-schedules-list"; +import { useDebounce } from "@/hooks/use-debounce"; + +export default function PmSchedulesListPage() { + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(12); + const [deleteTarget, setDeleteTarget] = useState(null); + + const debouncedSearch = useDebounce(search, 300); + const deletePmSchedule = useDeletePmSchedule(); + + const listParams = useMemo( + () => ({ + search: debouncedSearch, + page: page + 1, + pageSize, + }), + [debouncedSearch, page, pageSize], + ); + + const { data, isLoading, isFetching, error } = usePmSchedulesList(listParams); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + + const handleDeleteConfirm = () => { + if (!deleteTarget) return; + deletePmSchedule.mutate(deleteTarget.id, { + onSuccess: () => setDeleteTarget(null), + }); + }; + + return ( + + + + + PM Schedules + + + {totalCount} total + + {isFetching && !isLoading && } + + + + { + setSearch(event.target.value); + setPage(0); + }} + placeholder="Search PM schedules..." + className="min-w-[220px]" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load PM schedules"} + + )} + + + + + + Asset + Frequency + Last Date + Next Date + Status + Assigned To + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No PM schedules found + + + ) : ( + rows.map((row) => ( + + + + + {row.frequency || "—"} + {row.lastDate || "—"} + {row.nextDate || "—"} + {row.status || "—"} + {row.assignedTo || "—"} + + + navigate(`/pmschedules/edit/${row.id}`)} + > + + + + + setDeleteTarget(row)} + > + + + + + + )) + )} + +
+
+ + setPage(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(event) => { + setPageSize(Number(event.target.value)); + setPage(0); + }} + rowsPerPageOptions={[12, 24, 48]} + /> + + setDeleteTarget(null)}> + Delete PM Schedule + + + Are you sure you want to delete "{deleteTarget?.asset || deleteTarget?.name}"? + This action cannot be undone. + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/pmschedules/new.tsx b/src/app/(protected)/pmschedules/new.tsx new file mode 100644 index 00000000..1095dbc3 --- /dev/null +++ b/src/app/(protected)/pmschedules/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/pm-schedule-form"; diff --git a/src/domain/pm-schedules/api/pm-schedules-api.ts b/src/domain/pm-schedules/api/pm-schedules-api.ts new file mode 100644 index 00000000..6fbf00bd --- /dev/null +++ b/src/domain/pm-schedules/api/pm-schedules-api.ts @@ -0,0 +1,57 @@ +import { API_PATHS } from "@/api/api-paths"; +import { apiGet, apiPost } from "@/api/api"; +import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; +import { + mapPmSchedule, + mapPmScheduleListItem, + mapPmScheduleToSavePayload, +} from "@/domain/pm-schedules/mappers/pm-schedule-mapper"; +import type { + PmSchedule, + PmSchedulesListParams, + PmSchedulesListResult, +} from "@/domain/pm-schedules/types/pm-schedule"; + +export const pmSchedulesApi = { + getList: async (params: PmSchedulesListParams = {}): Promise => { + const data = await apiGet(API_PATHS.pmSchedule.getList, { + searchParams: { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }, + }); + + const paginated = handlePaginatedResponse(data, { + page: params.page, + pageSize: params.pageSize, + }); + + return { + items: paginated.items.map(mapPmScheduleListItem), + totalCount: paginated.totalCount, + page: paginated.page, + pageSize: paginated.pageSize, + }; + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(API_PATHS.pmSchedule.getById, { + searchParams: { id }, + }); + return mapPmSchedule(handleApiResponse(data)); + }, + + /** Create and update both use PMSchedule/Save — Id=0 for create. */ + save: async (schedule: Partial & { id?: number }): Promise => { + const payload = mapPmScheduleToSavePayload(schedule); + const data = await apiPost(API_PATHS.pmSchedule.save, payload); + return mapPmSchedule(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiPost(API_PATHS.pmSchedule.delete, undefined, { + searchParams: { id }, + }); + }, +}; diff --git a/src/domain/pm-schedules/mappers/pm-schedule-mapper.ts b/src/domain/pm-schedules/mappers/pm-schedule-mapper.ts new file mode 100644 index 00000000..0491b1d9 --- /dev/null +++ b/src/domain/pm-schedules/mappers/pm-schedule-mapper.ts @@ -0,0 +1,107 @@ +import type { PmSchedule, PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; +} + +function readNumber(record: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number") { + return value; + } + } + return null; +} + +export function mapPmScheduleListItem(raw: unknown): PmScheduleListItem { + const record = isRecord(raw) ? raw : {}; + + return { + id: readNumber(record, "id", "Id") ?? 0, + asset: readString(record, "asset", "Asset", "assetCode", "AssetCode", "name", "Name"), + frequency: readString(record, "frequency", "Frequency"), + lastDate: readString(record, "lastDate", "LastDate", "lastServiceDate", "LastServiceDate"), + nextDate: readString( + record, + "nextDate", + "NextDate", + "nextServiceDate", + "NextServiceDate", + "startDate", + "StartDate", + ), + status: readString(record, "status", "Status", "isActive", "IsActive") || "Active", + assignedTo: readString(record, "assignedTo", "AssignedTo"), + name: readString(record, "name", "Name", "assetName", "AssetName"), + }; +} + +export function mapPmSchedule(raw: unknown): PmSchedule { + const record = isRecord(raw) ? raw : {}; + const listItem = mapPmScheduleListItem(raw); + const isActive = record.isActive ?? record.IsActive; + + return { + id: listItem.id, + asset: readString(record, "asset", "Asset", "assetCode", "AssetCode") || listItem.asset, + assetName: + readString(record, "assetName", "AssetName", "name", "Name") || + listItem.name || + listItem.asset, + assetId: readNumber(record, "assetId", "AssetId"), + locationId: readNumber(record, "locationId", "LocationId"), + frequency: listItem.frequency || "Quarterly", + lastServiceDate: readString( + record, + "lastServiceDate", + "LastServiceDate", + "lastDate", + "LastDate", + ), + nextServiceDate: + readString( + record, + "nextServiceDate", + "NextServiceDate", + "nextDate", + "NextDate", + "startDate", + "StartDate", + ) || listItem.nextDate, + status: + typeof isActive === "boolean" + ? isActive + ? "Active" + : "Inactive" + : listItem.status || "Active", + assignedTo: listItem.assignedTo, + notes: readString(record, "notes", "Notes"), + }; +} + +/** Unified save payload — always uses PMSchedule/Save (fixes Create vs Save inconsistency). */ +export function mapPmScheduleToSavePayload(schedule: Partial & { id?: number }) { + const isActive = + schedule.status === "Active" || schedule.status === undefined || schedule.status === ""; + + return { + Id: schedule.id ?? 0, + Name: schedule.assetName || schedule.asset || "", + Frequency: schedule.frequency || "Quarterly", + StartDate: schedule.nextServiceDate || "", + IsActive: isActive, + AssetId: schedule.assetId ?? null, + LocationId: schedule.locationId ?? null, + }; +} diff --git a/src/domain/pm-schedules/schemas/pm-schedule-schema.ts b/src/domain/pm-schedules/schemas/pm-schedule-schema.ts new file mode 100644 index 00000000..02fbc481 --- /dev/null +++ b/src/domain/pm-schedules/schemas/pm-schedule-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const pmScheduleSchema = z.object({ + asset: z.string().min(1, "Asset ID is required"), + assetName: z.string().min(1, "Asset name is required"), + frequency: z.enum(["Daily", "Weekly", "Monthly", "Quarterly", "Semi-Annual", "Annual"]), + lastServiceDate: z.string().min(1, "Last service date is required"), + nextServiceDate: z.string().min(1, "Next service date is required"), + status: z.enum(["Active", "Inactive", "Suspended"]), + assignedTo: z.string().optional(), + notes: z.string().optional(), +}); + +export type PmScheduleFormValues = z.infer; diff --git a/src/domain/pm-schedules/types/pm-schedule.ts b/src/domain/pm-schedules/types/pm-schedule.ts new file mode 100644 index 00000000..03109c6a --- /dev/null +++ b/src/domain/pm-schedules/types/pm-schedule.ts @@ -0,0 +1,37 @@ +export interface PmScheduleListItem { + id: number; + asset: string; + frequency: string; + lastDate: string; + nextDate: string; + status: string; + assignedTo: string; + name?: string; +} + +export interface PmSchedule { + id: number; + asset: string; + assetName: string; + assetId: number | null; + locationId: number | null; + frequency: string; + lastServiceDate: string; + nextServiceDate: string; + status: string; + assignedTo: string; + notes: string; +} + +export interface PmSchedulesListParams { + search?: string; + page?: number; + pageSize?: number; +} + +export interface PmSchedulesListResult { + items: PmScheduleListItem[]; + totalCount: number; + page: number; + pageSize: number; +} diff --git a/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts b/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts new file mode 100644 index 00000000..66594fce --- /dev/null +++ b/src/domain/pm-schedules/use-cases/use-delete-pm-schedule.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDeletePmSchedule(): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => pmSchedulesApi.delete(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all }); + toast.success("PM schedule deleted successfully!"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete PM schedule"); + }, + }); +} diff --git a/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts b/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts new file mode 100644 index 00000000..1f559234 --- /dev/null +++ b/src/domain/pm-schedules/use-cases/use-pm-schedule-detail.ts @@ -0,0 +1,13 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; +import type { PmSchedule } from "@/domain/pm-schedules/types/pm-schedule"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { requireQueryParam } from "@/lib/query/require-query-param"; + +export function usePmScheduleDetail(id?: string): UseQueryResult { + return useQuery({ + queryKey: queryKeys.pmSchedules.detail(id ?? ""), + queryFn: () => pmSchedulesApi.getById(requireQueryParam(id, "PM schedule id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/pm-schedules/use-cases/use-pm-schedules-list.ts b/src/domain/pm-schedules/use-cases/use-pm-schedules-list.ts new file mode 100644 index 00000000..f16755d4 --- /dev/null +++ b/src/domain/pm-schedules/use-cases/use-pm-schedules-list.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; +import type { + PmSchedulesListParams, + PmSchedulesListResult, +} from "@/domain/pm-schedules/types/pm-schedule"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function usePmSchedulesList( + params: PmSchedulesListParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.pmSchedules.list(params as Record), + queryFn: () => pmSchedulesApi.getList(params), + placeholderData: (previous) => previous, + }); +} diff --git a/src/domain/pm-schedules/use-cases/use-save-pm-schedule.ts b/src/domain/pm-schedules/use-cases/use-save-pm-schedule.ts new file mode 100644 index 00000000..d6e02105 --- /dev/null +++ b/src/domain/pm-schedules/use-cases/use-save-pm-schedule.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; +import type { PmScheduleFormValues } from "@/domain/pm-schedules/schemas/pm-schedule-schema"; +import type { PmSchedule } from "@/domain/pm-schedules/types/pm-schedule"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useSavePmSchedule( + id?: string, +): UseMutationResult { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (values: PmScheduleFormValues) => + pmSchedulesApi.save({ + id: id ? Number(id) : 0, + asset: values.asset, + assetName: values.assetName, + frequency: values.frequency, + lastServiceDate: values.lastServiceDate, + nextServiceDate: values.nextServiceDate, + status: values.status, + assignedTo: values.assignedTo, + notes: values.notes, + }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all }); + toast.success("PM schedule saved successfully!"); + navigate("/pmschedules"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to save PM schedule"); + }, + }); +}