mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 02:22:06 +00:00
fix(services): address registry review findings
This commit is contained in:
parent
1c66f8d60e
commit
9c360fc95b
9 changed files with 80 additions and 19 deletions
|
|
@ -143,6 +143,7 @@ type EditorProps = {
|
||||||
formError: string;
|
formError: string;
|
||||||
trades: DropdownOption[];
|
trades: DropdownOption[];
|
||||||
templates: CompletionDocTemplateOption[];
|
templates: CompletionDocTemplateOption[];
|
||||||
|
canChangeActiveState: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
|
@ -156,6 +157,7 @@ export function ServiceEditorDrawer({
|
||||||
formError,
|
formError,
|
||||||
trades,
|
trades,
|
||||||
templates,
|
templates,
|
||||||
|
canChangeActiveState,
|
||||||
isSaving,
|
isSaving,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -250,6 +252,17 @@ export function ServiceEditorDrawer({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{mode === "edit" && canChangeActiveState && (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={form.isActive ?? true}
|
||||||
|
onChange={(event) => onUpdate("isActive", event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Active"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography variant="subtitle2" className="font-semibold">
|
<Typography variant="subtitle2" className="font-semibold">
|
||||||
Completion Document
|
Completion Document
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export function ServicesRegistryView() {
|
||||||
) : controller.filteredServices.length === 0 ? (
|
) : controller.filteredServices.length === 0 ? (
|
||||||
<EmptyServices
|
<EmptyServices
|
||||||
search={Boolean(controller.search)}
|
search={Boolean(controller.search)}
|
||||||
|
status={controller.status}
|
||||||
onAdd={controller.canManage ? controller.openCreate : undefined}
|
onAdd={controller.canManage ? controller.openCreate : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -163,6 +164,7 @@ export function ServicesRegistryView() {
|
||||||
formError={controller.formError}
|
formError={controller.formError}
|
||||||
trades={controller.trades}
|
trades={controller.trades}
|
||||||
templates={controller.templates}
|
templates={controller.templates}
|
||||||
|
canChangeActiveState={controller.canDeactivate}
|
||||||
isSaving={controller.isSaving}
|
isSaving={controller.isSaving}
|
||||||
onClose={controller.closeEditor}
|
onClose={controller.closeEditor}
|
||||||
onSave={controller.save}
|
onSave={controller.save}
|
||||||
|
|
@ -180,16 +182,31 @@ export function ServicesRegistryView() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyServices({ search, onAdd }: { search: boolean; onAdd?: () => void }) {
|
function EmptyServices({
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
search: boolean;
|
||||||
|
status: ServiceStatus;
|
||||||
|
onAdd?: () => void;
|
||||||
|
}) {
|
||||||
|
const statusLabel = status === "active" ? "active" : "inactive";
|
||||||
return (
|
return (
|
||||||
<Box className="rounded-lg border border-dashed border-border bg-background py-16 text-center">
|
<Box className="rounded-lg border border-dashed border-border bg-background py-16 text-center">
|
||||||
<Typography variant="body1" className="font-semibold">
|
<Typography variant="body1" className="font-semibold">
|
||||||
{search ? "No services found" : "No services configured yet"}
|
{search
|
||||||
|
? "No services found"
|
||||||
|
: status === "all"
|
||||||
|
? "No services configured yet"
|
||||||
|
: `No ${statusLabel} services configured yet`}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||||
{search
|
{search
|
||||||
? "Try a different search, or add a new service."
|
? "Try a different search, or add a new service."
|
||||||
: "Add a service to make it available for work orders."}
|
: status === "all"
|
||||||
|
? "Add a service to make it available for work orders."
|
||||||
|
: `There are no ${statusLabel} services matching this view.`}
|
||||||
</Typography>
|
</Typography>
|
||||||
{onAdd !== undefined && (
|
{onAdd !== undefined && (
|
||||||
<Button variant="outlined" className="mt-4" onClick={onAdd}>
|
<Button variant="outlined" className="mt-4" onClick={onAdd}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiGet, apiPost, apiPut } from "@/api/api";
|
import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { handleApiResponse } from "@/api/handle-api-response";
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
|
|
@ -51,6 +51,6 @@ export const servicesApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
deactivate: async (id: string | number): Promise<void> => {
|
deactivate: async (id: string | number): Promise<void> => {
|
||||||
await apiPost(API_PATHS.services.deactivate(id));
|
await apiPostNoContent(API_PATHS.services.deactivate(id));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,11 @@ export function mapService(raw: unknown): Service {
|
||||||
"requiresCompletionDocument",
|
"requiresCompletionDocument",
|
||||||
"RequiresCompletionDocument",
|
"RequiresCompletionDocument",
|
||||||
),
|
),
|
||||||
completionDocTemplate: mapTemplate(
|
completionDocTemplate:
|
||||||
record.completionDocTemplate ?? record.CompletionDocTemplate,
|
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
|
||||||
),
|
mapTemplate({
|
||||||
|
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
|
||||||
|
}),
|
||||||
isActive: readBool(record, "isActive", "IsActive"),
|
isActive: readBool(record, "isActive", "IsActive"),
|
||||||
supportedWorkOrderTypes: Array.isArray(types)
|
supportedWorkOrderTypes: Array.isArray(types)
|
||||||
? types.flatMap((value) => {
|
? types.flatMap((value) => {
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,5 @@ export interface ServiceInput {
|
||||||
requiresCompletionDocument: boolean;
|
requiresCompletionDocument: boolean;
|
||||||
completionDocTemplateId: number | null;
|
completionDocTemplateId: number | null;
|
||||||
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
supportedWorkOrderTypes: ServiceWorkOrderType[];
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
import { getPrimaryUserRole, isAdminUser } from "@/lib/auth/user-utils";
|
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
|
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +32,10 @@ export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
|
||||||
"General Building": "hammer",
|
"General Building": "hammer",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildServiceInput(form: ServiceForm): {
|
export function buildServiceInput(
|
||||||
|
form: ServiceForm,
|
||||||
|
includeActiveState = false,
|
||||||
|
): {
|
||||||
input: ServiceInput | null;
|
input: ServiceInput | null;
|
||||||
error: string;
|
error: string;
|
||||||
} {
|
} {
|
||||||
|
|
@ -50,11 +53,13 @@ export function buildServiceInput(form: ServiceForm): {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const templateId = form.completionDocTemplateId;
|
const templateId = form.completionDocTemplateId;
|
||||||
|
const { isActive, ...editableFields } = form;
|
||||||
return {
|
return {
|
||||||
input: {
|
input: {
|
||||||
...form,
|
...editableFields,
|
||||||
name,
|
name,
|
||||||
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
|
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
|
||||||
|
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
|
||||||
},
|
},
|
||||||
error: "",
|
error: "",
|
||||||
};
|
};
|
||||||
|
|
@ -75,13 +80,13 @@ function formFromService(service: Service): ServiceForm {
|
||||||
requiresCompletionDocument: service.requiresCompletionDocument,
|
requiresCompletionDocument: service.requiresCompletionDocument,
|
||||||
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
|
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
|
||||||
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
|
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
|
||||||
|
isActive: service.isActive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServicesRegistryController() {
|
export function useServicesRegistryController() {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const role = getPrimaryUserRole(user?.userRoles).toLowerCase();
|
const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles);
|
||||||
const canManage = isAdminUser(user?.userRoles) || role === "scheduler";
|
|
||||||
const canDeactivate = isAdminUser(user?.userRoles);
|
const canDeactivate = isAdminUser(user?.userRoles);
|
||||||
const [status, setStatus] = useState<ServiceStatus>("active");
|
const [status, setStatus] = useState<ServiceStatus>("active");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
@ -154,7 +159,7 @@ export function useServicesRegistryController() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const validation = buildServiceInput(form);
|
const validation = buildServiceInput(form, canDeactivate);
|
||||||
if (!validation.input) {
|
if (!validation.input) {
|
||||||
setFormError(validation.error);
|
setFormError(validation.error);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,16 @@ export function getPrimaryUserRole(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAdminUser(userRoles: string | null | undefined): boolean {
|
export function isAdminUser(userRoles: string | null | undefined): boolean {
|
||||||
|
return hasUserRole(userRoles, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
|
||||||
if (!userRoles) {
|
if (!userRoles) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const normalizedExpectedRole = expectedRole.trim().toLowerCase();
|
||||||
return userRoles
|
return userRoles
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((role) => role.trim().toLowerCase())
|
.map((role) => role.trim().toLowerCase())
|
||||||
.includes("admin");
|
.includes(normalizedExpectedRole);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import type { ServiceInput } from "@/domain/services/types/service";
|
||||||
|
|
||||||
const apiGet = vi.fn();
|
const apiGet = vi.fn();
|
||||||
const apiPost = vi.fn();
|
const apiPost = vi.fn();
|
||||||
|
const apiPostNoContent = vi.fn();
|
||||||
const apiPut = vi.fn();
|
const apiPut = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/api/api", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
apiPost: (...args: unknown[]) => apiPost(...args),
|
apiPost: (...args: unknown[]) => apiPost(...args),
|
||||||
|
apiPostNoContent: (...args: unknown[]) => apiPostNoContent(...args),
|
||||||
apiPut: (...args: unknown[]) => apiPut(...args),
|
apiPut: (...args: unknown[]) => apiPut(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -18,6 +20,7 @@ describe("servicesApi", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiGet.mockReset();
|
apiGet.mockReset();
|
||||||
apiPost.mockReset();
|
apiPost.mockReset();
|
||||||
|
apiPostNoContent.mockReset();
|
||||||
apiPut.mockReset();
|
apiPut.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,21 +64,29 @@ describe("servicesApi", () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
apiPost.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
|
apiPost.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
|
||||||
apiPut.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
|
apiPut.mockResolvedValue({
|
||||||
|
id: 8,
|
||||||
|
...input,
|
||||||
|
completionDocTemplateId: 12,
|
||||||
|
isActive: true,
|
||||||
|
supportedWorkOrderTypes: [2],
|
||||||
|
});
|
||||||
|
apiPostNoContent.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await servicesApi.create(input);
|
await servicesApi.create(input);
|
||||||
await servicesApi.update(8, input);
|
const updated = await servicesApi.update(8, input);
|
||||||
await servicesApi.deactivate(8);
|
await servicesApi.deactivate(8);
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
|
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
|
||||||
...input,
|
...input,
|
||||||
supportedWorkOrderTypes: [2],
|
supportedWorkOrderTypes: [2],
|
||||||
});
|
});
|
||||||
expect(apiPost).toHaveBeenNthCalledWith(2, API_PATHS.services.deactivate(8));
|
expect(apiPostNoContent).toHaveBeenCalledWith(API_PATHS.services.deactivate(8));
|
||||||
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
|
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
|
||||||
...input,
|
...input,
|
||||||
supportedWorkOrderTypes: [2],
|
supportedWorkOrderTypes: [2],
|
||||||
});
|
});
|
||||||
|
expect(updated.completionDocTemplate).toEqual({ id: 12, name: "" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads active Completion Document Template options from the completion route", async () => {
|
it("loads active Completion Document Template options from the completion route", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
|
||||||
|
|
||||||
describe("isAdminUser", () => {
|
describe("isAdminUser", () => {
|
||||||
it("returns true when Admin appears in roles", () => {
|
it("returns true when Admin appears in roles", () => {
|
||||||
|
|
@ -13,3 +13,10 @@ describe("isAdminUser", () => {
|
||||||
expect(isAdminUser(undefined)).toBe(false);
|
expect(isAdminUser(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hasUserRole", () => {
|
||||||
|
it("finds a role regardless of its position", () => {
|
||||||
|
expect(hasUserRole("Dispatcher,Scheduler", "Scheduler")).toBe(true);
|
||||||
|
expect(hasUserRole("Dispatcher", "Scheduler")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue