mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 21:52:08 +00:00
fix(services): replace status filter with active/inactive tabs (SH-304)
This commit is contained in:
parent
2fb5d4a965
commit
f39328576e
3 changed files with 224 additions and 42 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue