fix(pm-schedules): gate edit/delete for Dev API contract

Production Dev API exposes only GetList and Save.

Hide unsupported edit/delete UI and block the edit route.

Add regression tests for disabled actions.
This commit is contained in:
Arthur Bassi 2026-06-16 17:03:07 -03:00
parent 2a210eeab0
commit 06d0a8ecf4
8 changed files with 251 additions and 62 deletions

View file

@ -0,0 +1,33 @@
import { Alert, Box, Button, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
export function PmScheduleEditUnavailablePanel() {
return (
<Box className="mx-auto max-w-3xl">
<Alert severity="warning" className="mb-4">
PM Schedule editing is unavailable — the production API does not expose{" "}
<code className="text-sm">PMSchedule/GetById</code>.
</Alert>
<Typography variant="body2" color="text.secondary">
List and create remain available via <code className="text-sm">GetList</code> and{" "}
<code className="text-sm">Save</code>. Enable editing after the backend Dev branch exposes
GetById.
</Typography>
</Box>
);
}
export default function PmScheduleEditUnavailablePage() {
const navigate = useNavigate();
return (
<Box className="p-4">
<PmScheduleEditUnavailablePanel />
<Box className="mt-4 flex justify-end">
<Button variant="outlined" onClick={() => navigate("/pmschedules")}>
Back to PM Schedules
</Button>
</Box>
</Box>
);
}

View file

@ -1 +1,5 @@
export { default } from "../_components/pm-schedule-form"; import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
import PmScheduleFormPage from "../_components/pm-schedule-form";
import PmScheduleEditUnavailablePage from "../_components/pm-schedule-unavailable";
export default pmScheduleCapabilities.canEdit ? PmScheduleFormPage : PmScheduleEditUnavailablePage;

View file

@ -27,6 +27,7 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
import type { PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule"; import type { PmScheduleListItem } from "@/domain/pm-schedules/types/pm-schedule";
import { useDeletePmSchedule } from "@/domain/pm-schedules/use-cases/use-delete-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 { usePmSchedulesList } from "@/domain/pm-schedules/use-cases/use-pm-schedules-list";
@ -42,6 +43,9 @@ export default function PmSchedulesListPage() {
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const deletePmSchedule = useDeletePmSchedule(); const deletePmSchedule = useDeletePmSchedule();
const showActionsColumn = pmScheduleCapabilities.canEdit || pmScheduleCapabilities.canDelete;
const columnCount = showActionsColumn ? 7 : 6;
const listParams = useMemo( const listParams = useMemo(
() => ({ () => ({
search: debouncedSearch, search: debouncedSearch,
@ -57,7 +61,7 @@ export default function PmSchedulesListPage() {
const totalCount = data?.totalCount ?? 0; const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => { const handleDeleteConfirm = () => {
if (!deleteTarget) return; if (!deleteTarget || !pmScheduleCapabilities.canDelete) return;
deletePmSchedule.mutate(deleteTarget.id, { deletePmSchedule.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null), onSuccess: () => setDeleteTarget(null),
}); });
@ -92,9 +96,11 @@ export default function PmSchedulesListPage() {
placeholder="Search PM schedules..." placeholder="Search PM schedules..."
className="min-w-[220px]" className="min-w-[220px]"
/> />
<Button variant="contained" onClick={() => navigate("/pmschedules/new")}> {pmScheduleCapabilities.canCreate && (
+ New PM Schedule <Button variant="contained" onClick={() => navigate("/pmschedules/new")}>
</Button> + New PM Schedule
</Button>
)}
</Stack> </Stack>
</Stack> </Stack>
@ -114,21 +120,23 @@ export default function PmSchedulesListPage() {
<TableCell className="font-semibold">Status</TableCell> <TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Asset</TableCell> <TableCell className="font-semibold">Asset</TableCell>
<TableCell className="font-semibold">Location</TableCell> <TableCell className="font-semibold">Location</TableCell>
<TableCell align="right" className="font-semibold"> {showActionsColumn && (
Actions <TableCell align="right" className="font-semibold">
</TableCell> Actions
</TableCell>
)}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} align="center" className="py-10"> <TableCell colSpan={columnCount} align="center" className="py-10">
<CircularProgress size={28} /> <CircularProgress size={28} />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} align="center" className="py-10"> <TableCell colSpan={columnCount} align="center" className="py-10">
<Typography color="text.secondary">No PM schedules found</Typography> <Typography color="text.secondary">No PM schedules found</Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -136,14 +144,20 @@ export default function PmSchedulesListPage() {
rows.map((row) => ( rows.map((row) => (
<TableRow key={String(row.id)} hover> <TableRow key={String(row.id)} hover>
<TableCell> <TableCell>
<Button {pmScheduleCapabilities.canEdit ? (
variant="text" <Button
size="small" variant="text"
className="font-semibold" size="small"
onClick={() => navigate(`/pmschedules/edit/${row.id}`)} className="font-semibold"
> onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
{row.name || "—"} >
</Button> {row.name || "—"}
</Button>
) : (
<Typography variant="body2" className="font-semibold">
{row.name || "—"}
</Typography>
)}
</TableCell> </TableCell>
<TableCell>{row.frequency || "—"}</TableCell> <TableCell>{row.frequency || "—"}</TableCell>
<TableCell>{row.startDate || "—"}</TableCell> <TableCell>{row.startDate || "—"}</TableCell>
@ -157,26 +171,32 @@ export default function PmSchedulesListPage() {
</TableCell> </TableCell>
<TableCell>{row.asset || "—"}</TableCell> <TableCell>{row.asset || "—"}</TableCell>
<TableCell>{row.location || "—"}</TableCell> <TableCell>{row.location || "—"}</TableCell>
<TableCell align="right"> {showActionsColumn && (
<Tooltip title="Edit"> <TableCell align="right">
<IconButton {pmScheduleCapabilities.canEdit && (
size="small" <Tooltip title="Edit">
aria-label="Edit PM schedule" <IconButton
onClick={() => navigate(`/pmschedules/edit/${row.id}`)} size="small"
> aria-label="Edit PM schedule"
<EditOutlinedIcon fontSize="small" /> onClick={() => navigate(`/pmschedules/edit/${row.id}`)}
</IconButton> >
</Tooltip> <EditOutlinedIcon fontSize="small" />
<Tooltip title="Delete"> </IconButton>
<IconButton </Tooltip>
size="small" )}
aria-label="Delete PM schedule" {pmScheduleCapabilities.canDelete && (
onClick={() => setDeleteTarget(row)} <Tooltip title="Delete">
> <IconButton
<DeleteOutlineIcon fontSize="small" /> size="small"
</IconButton> aria-label="Delete PM schedule"
</Tooltip> onClick={() => setDeleteTarget(row)}
</TableCell> >
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
)}
</TableRow> </TableRow>
)) ))
)} )}
@ -197,28 +217,30 @@ export default function PmSchedulesListPage() {
rowsPerPageOptions={[12, 24, 48]} rowsPerPageOptions={[12, 24, 48]}
/> />
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}> {pmScheduleCapabilities.canDelete && (
<DialogTitle>Delete PM Schedule</DialogTitle> <Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogContent> <DialogTitle>Delete PM Schedule</DialogTitle>
<DialogContentText> <DialogContent>
Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be <DialogContentText>
undone. Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot
</DialogContentText> be undone.
</DialogContent> </DialogContentText>
<DialogActions> </DialogContent>
<Button onClick={() => setDeleteTarget(null)} disabled={deletePmSchedule.isPending}> <DialogActions>
Cancel <Button onClick={() => setDeleteTarget(null)} disabled={deletePmSchedule.isPending}>
</Button> Cancel
<Button </Button>
color="error" <Button
variant="contained" color="error"
onClick={handleDeleteConfirm} variant="contained"
disabled={deletePmSchedule.isPending} onClick={handleDeleteConfirm}
> disabled={deletePmSchedule.isPending}
{deletePmSchedule.isPending ? "Deleting..." : "Delete"} >
</Button> {deletePmSchedule.isPending ? "Deleting..." : "Delete"}
</DialogActions> </Button>
</Dialog> </DialogActions>
</Dialog>
)}
</Box> </Box>
); );
} }

View file

@ -0,0 +1,20 @@
/**
* PM Schedule API capabilities aligned with production backend (`origin/Dev`).
*
* Exposed controller endpoints on Dev:
* - GET PMSchedule/GetList
* - POST PMSchedule/Save (Id=0 create, Id>0 update)
*
* Not exposed on Dev (present on `main` after 59385cf):
* - GET PMSchedule/GetById
* - POST PMSchedule/Delete
*
* Flip flags to true only after Dev exposes the matching endpoints.
*/
export const pmScheduleCapabilities = {
canCreate: true,
canEdit: false,
canDelete: false,
} as const;
export type PmScheduleCapabilities = typeof pmScheduleCapabilities;

View file

@ -1,5 +1,6 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
@ -7,7 +8,13 @@ export function useDeletePmSchedule(): UseMutationResult<void, Error, number> {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: number) => pmSchedulesApi.delete(id), mutationFn: (id: number) => {
if (!pmScheduleCapabilities.canDelete) {
return Promise.reject(new Error("PM Schedule delete is not supported by the current API"));
}
return pmSchedulesApi.delete(id);
},
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all }); void queryClient.invalidateQueries({ queryKey: queryKeys.pmSchedules.all });
toast.success("PM schedule deleted successfully!"); toast.success("PM schedule deleted successfully!");

View file

@ -1,4 +1,5 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api"; import { pmSchedulesApi } from "@/domain/pm-schedules/api/pm-schedules-api";
import type { PmSchedule } from "@/domain/pm-schedules/types/pm-schedule"; import type { PmSchedule } from "@/domain/pm-schedules/types/pm-schedule";
import { queryKeys } from "@/infra/query-key/query-key"; import { queryKeys } from "@/infra/query-key/query-key";
@ -8,6 +9,6 @@ export function usePmScheduleDetail(id?: string): UseQueryResult<PmSchedule, Err
return useQuery({ return useQuery({
queryKey: queryKeys.pmSchedules.detail(id ?? ""), queryKey: queryKeys.pmSchedules.detail(id ?? ""),
queryFn: () => pmSchedulesApi.getById(requireQueryParam(id, "PM schedule id")), queryFn: () => pmSchedulesApi.getById(requireQueryParam(id, "PM schedule id")),
enabled: Boolean(id), enabled: Boolean(id) && pmScheduleCapabilities.canEdit,
}); });
} }

View file

@ -0,0 +1,92 @@
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PmSchedulesListPage from "@/app/(protected)/pmschedules/index";
import { renderWithProviders } from "@/test/test-utils";
const usePmSchedulesList = vi.fn();
const useDeletePmSchedule = vi.fn();
vi.mock("@/domain/pm-schedules/use-cases/use-pm-schedules-list", () => ({
usePmSchedulesList: (...args: unknown[]) => usePmSchedulesList(...args),
}));
vi.mock("@/domain/pm-schedules/use-cases/use-delete-pm-schedule", () => ({
useDeletePmSchedule: (...args: unknown[]) => useDeletePmSchedule(...args),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const mockRow = {
id: 1,
name: "Quarterly HVAC",
frequency: "Quarterly",
startDate: "2026-01-01",
isActive: true,
asset: "Unit A",
location: "Building 1",
};
describe("PmSchedulesListPage actions", () => {
it("does not render edit or delete actions when capabilities are disabled", () => {
useDeletePmSchedule.mockReturnValue({
mutate: vi.fn(),
isPending: false,
});
usePmSchedulesList.mockReturnValue({
data: {
items: [mockRow],
totalCount: 1,
page: 1,
pageSize: 12,
},
isLoading: false,
isFetching: false,
error: null,
});
renderWithProviders(<PmSchedulesListPage />, { route: "/pmschedules", withAuth: false });
expect(screen.queryByLabelText("Edit PM schedule")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Delete PM schedule")).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Quarterly HVAC" })).not.toBeInTheDocument();
expect(screen.getByText("Quarterly HVAC")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("still renders create when canCreate is enabled", () => {
useDeletePmSchedule.mockReturnValue({
mutate: vi.fn(),
isPending: false,
});
usePmSchedulesList.mockReturnValue({
data: { items: [], totalCount: 0, page: 1, pageSize: 12 },
isLoading: false,
isFetching: false,
error: null,
});
renderWithProviders(<PmSchedulesListPage />, { route: "/pmschedules", withAuth: false });
expect(screen.getByRole("button", { name: "+ New PM Schedule" })).toBeInTheDocument();
});
});
describe("PmScheduleEditUnavailablePage", () => {
it("shows unavailable message instead of edit form when canEdit is false", async () => {
const EditPage = (await import("@/app/(protected)/pmschedules/edit/[id]")).default;
renderWithProviders(<EditPage />, {
route: "/pmschedules/edit/1",
routerProps: { initialEntries: ["/pmschedules/edit/1"] },
withAuth: false,
});
expect(screen.getByText(/PM Schedule editing is unavailable/i)).toBeInTheDocument();
expect(screen.getByText(/PMSchedule\/GetById/i)).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Edit PM Schedule" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Back to PM Schedules" })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,10 @@
import { pmScheduleCapabilities } from "@/config/pm-schedule-capabilities";
import { describe, expect, it } from "vitest";
describe("pmScheduleCapabilities", () => {
it("reflects production Dev API contract (GetList + Save only)", () => {
expect(pmScheduleCapabilities.canCreate).toBe(true);
expect(pmScheduleCapabilities.canEdit).toBe(false);
expect(pmScheduleCapabilities.canDelete).toBe(false);
});
});