mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 20:32:05 +00:00
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:
parent
2a210eeab0
commit
06d0a8ecf4
8 changed files with 251 additions and 62 deletions
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 "{deleteTarget?.name}"? This action cannot be
|
<DialogContentText>
|
||||||
undone.
|
Are you sure you want to delete "{deleteTarget?.name}"? 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
src/config/pm-schedule-capabilities.ts
Normal file
20
src/config/pm-schedule-capabilities.ts
Normal 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;
|
||||||
|
|
@ -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!");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/test/config/pm-schedule-capabilities.test.ts
Normal file
10
src/test/config/pm-schedule-capabilities.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue