fix(services): replace status filter with active/inactive tabs (SH-304)

This commit is contained in:
Codex Review Integration 2026-09-17 02:01:08 -03:00
parent 2fb5d4a965
commit f39328576e
3 changed files with 224 additions and 42 deletions

View file

@ -3,20 +3,18 @@ import {
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { Plus } from "lucide-react";
import { Plus, Search } from "lucide-react";
import { SettingsNav } from "@/components/common/settings-nav";
import {
useServicesRegistryController,
@ -30,9 +28,23 @@ import {
ServiceIcon,
} from "@/app/(protected)/settings/_components/services-registry-components";
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
export function ServicesRegistryView() {
const controller = useServicesRegistryController();
const activeCount = controller.services.filter((service) => service.isActive).length;
return (
<Box className="p-4 md:p-8">
@ -49,7 +61,7 @@ export function ServicesRegistryView() {
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{activeCount} active
{controller.activeCount} active
</Typography>
</Box>
{controller.canManage && (
@ -68,25 +80,44 @@ export function ServicesRegistryView() {
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search services..."
placeholder="Search service or trade..."
className="flex-1"
aria-label="Search services"
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
/>
<FormControl size="small" className="min-w-[150px]">
<InputLabel id="service-status-label">Status</InputLabel>
<Select
labelId="service-status-label"
label="Status"
value={controller.status}
onChange={(event) => controller.setStatus(event.target.value as ServiceStatus)}
>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="inactive">Inactive</MenuItem>
<MenuItem value="all">All services</MenuItem>
</Select>
</FormControl>
</Stack>
<Tabs
value={controller.status}
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
aria-label="Service status"
className="mb-4"
sx={{
minHeight: 36,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", controller.activeCount)}
aria-label={`Active (${controller.activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", controller.inactiveCount)}
aria-label={`Inactive (${controller.inactiveCount})`}
/>
</Tabs>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
@ -104,6 +135,27 @@ export function ServicesRegistryView() {
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
)}
{!controller.isLoading && (
<Stack
direction="row"
className="mt-2"
sx={{
justifyContent: "space-between",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
pt: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{controller.filteredServices.length} {controller.status} service
{controller.filteredServices.length === 1 ? "" : "s"}
{controller.search.trim() ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{controller.activeCount} active · {controller.inactiveCount} inactive
</Typography>
</Stack>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
@ -204,22 +256,18 @@ function EmptyServices({
status: ServiceStatus;
onAdd?: () => void;
}) {
const statusLabel = status === "active" ? "active" : "inactive";
return (
<Box className="rounded-lg border border-dashed border-border bg-background py-16 text-center">
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
<Search size={22} aria-hidden="true" />
</Box>
<Typography variant="body1" className="font-semibold">
{search
? "No services found"
: status === "all"
? "No services configured yet"
: `No ${statusLabel} services configured yet`}
{search ? "No services found" : `No ${status} services configured yet`}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: status === "all"
? "Add a service to make it available for work orders."
: `There are no ${statusLabel} services matching this view.`}
: `There are no ${status} services matching this view.`}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>

View file

@ -11,7 +11,7 @@ import {
useUpdateService,
} from "@/domain/services/use-cases/use-services";
export type ServiceStatus = "all" | "active" | "inactive";
export type ServiceStatus = "active" | "inactive";
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
completionDocTemplateId: string | number | null;
};
@ -97,22 +97,28 @@ export function useServicesRegistryController() {
const [formError, setFormError] = useState("");
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const queryStatus = status === "active" ? true : status === "inactive" ? false : undefined;
const servicesQuery = useServices(queryStatus);
const servicesQuery = useServices();
const { data: trades = [] } = useDropdownOptionsByCategory("Trade");
const { data: templates = [] } = useCompletionDocTemplates();
const createService = useCreateService();
const updateService = useUpdateService();
const deactivateService = useDeactivateService();
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const activeCount = services.filter((service) => service.isActive).length;
const inactiveCount = services.length - activeCount;
const filteredServices = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return servicesQuery.data ?? [];
return (servicesQuery.data ?? []).filter(
(service) =>
service.name.toLowerCase().includes(needle) || service.trade.toLowerCase().includes(needle),
);
}, [search, servicesQuery.data]);
return services
.filter((service) => (status === "active" ? service.isActive : !service.isActive))
.filter(
(service) =>
!needle ||
service.name.toLowerCase().includes(needle) ||
service.trade.toLowerCase().includes(needle),
);
}, [search, services, status]);
useEffect(() => {
if (!iconTouched && form.trade) {
@ -210,7 +216,9 @@ export function useServicesRegistryController() {
trades,
templates,
filteredServices,
services: servicesQuery.data ?? [],
services,
activeCount,
inactiveCount,
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
openCreate,

View file

@ -0,0 +1,126 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
import type { Service } from "@/domain/services/types/service";
import { renderWithProviders } from "@/test/test-utils";
const useServices = vi.fn();
const mutate = vi.fn();
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1", userRoles: "admin,scheduler" } }),
};
});
vi.mock("@/domain/services/use-cases/use-services", () => ({
useServices: (...args: unknown[]) => useServices(...args),
useCompletionDocTemplates: () => ({ data: [] }),
useCreateService: () => ({ mutate, isPending: false }),
useUpdateService: () => ({ mutate, isPending: false }),
useDeactivateService: () => ({ mutate, isPending: false }),
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [] }),
}));
const activeHvac: Service = {
id: 1,
name: "HVAC Repair",
trade: "HVAC",
iconKey: "zap",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive"],
};
const activeDrain: Service = {
...activeHvac,
id: 2,
name: "Drain Cleaning",
trade: "Plumbing",
};
const inactiveFire: Service = {
...activeHvac,
id: 3,
name: "Fire Suppression",
trade: "Fire Protection",
isActive: false,
};
function setup(services: Service[] = [activeHvac, activeDrain, inactiveFire]) {
useServices.mockReturnValue({
data: services,
isLoading: false,
error: null,
});
return renderWithProviders(<ServicesRegistryView />, { route: "/settings/services" });
}
describe("ServicesRegistryView", () => {
it("renders Active and Inactive tabs with live counts from the unfiltered list", () => {
setup();
expect(useServices).toHaveBeenCalledWith();
expect(screen.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Inactive (1)" })).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByText("3 services · 2 active")).toBeInTheDocument();
expect(screen.getByRole("row", { name: /HVAC Repair/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("switches to the Inactive tab and shows only inactive services", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByRole("row", { name: /Fire Suppression/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 inactive service")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("applies the search within the selected tab and reports the filtered footer count", async () => {
setup();
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "drain");
expect(screen.getByRole("row", { name: /Drain Cleaning/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 active service · filtered")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("shows an icon, message, and try-a-different-search hint when a tab and search combination is empty", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "hvac");
expect(screen.getByTestId("services-empty-icon")).toBeInTheDocument();
expect(screen.getByText("No services found")).toBeInTheDocument();
expect(screen.getByText("Try a different search, or add a new service.")).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("summarizes the filtered count and the overall split in the footer without a search", () => {
setup();
const footer = screen.getByText("2 active services").closest("div");
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getByText("2 active · 1 inactive")).toBeInTheDocument();
expect(screen.queryByText(/filtered/)).not.toBeInTheDocument();
});
});