fix(workorders): use service registry in list controls

This commit is contained in:
Alexandre Brandizzi 2026-09-30 17:01:38 -03:00
parent 29d235e133
commit 315a26c07d
33 changed files with 499 additions and 49 deletions

View file

@ -12,6 +12,7 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { Service } from "@/domain/services/types/service";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
import {
effectiveCompany,
@ -31,6 +32,7 @@ type SlideOverInfoTabDisplayViewProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
};
type FrozenPoc = ReturnType<typeof effectivePoc>;
@ -115,14 +117,22 @@ function SlideOverSiteField({
return <SlideOverCompletedSite source={source} poc={poc} sites={sites} />;
}
function SlideOverOpenService({ source }: { source: WorkOrderTableRow }) {
function SlideOverOpenService({
source,
services,
}: {
source: WorkOrderTableRow;
services: Service[];
}) {
if (!source.pm) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
const name =
services.find((service) => String(service.id) === String(source.serviceId))?.name ?? source.pm;
return (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
<PmTypeIcon pmType={name} size={14} />
{name}
{Boolean(source.avetaRequired) && (
<AvetaBadge size={11} attached={Boolean(source.hasAvetaDocument)} />
)}
@ -134,16 +144,21 @@ function SlideOverServiceField({
source,
isCompleted,
pmTypes,
services,
}: {
source: WorkOrderTableRow;
isCompleted: boolean;
pmTypes: string[];
services: Service[];
}) {
if (!isCompleted) return <SlideOverOpenService source={source} />;
if (!isCompleted) return <SlideOverOpenService source={source} services={services} />;
return (
<PMTypeCell
value={source.pm}
pmTypes={pmTypes}
services={services}
serviceId={source.serviceId}
workOrderType={source.type}
serviceNotes={source.serviceNotes}
extraServices={source.extraServices}
avetaRequired={source.avetaRequired}
@ -189,6 +204,7 @@ export function SlideOverInfoTabDisplayView({
sites,
vendors,
pmTypes,
services = [],
}: SlideOverInfoTabDisplayViewProps) {
const poc = effectivePoc(source);
@ -219,7 +235,12 @@ export function SlideOverInfoTabDisplayView({
<span className="text-[12.5px]">{formatApptDisplay(source.apptTime)}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
<SlideOverServiceField source={source} isCompleted={isCompleted} pmTypes={pmTypes} />
<SlideOverServiceField
source={source}
isCompleted={isCompleted}
pmTypes={pmTypes}
services={services}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<SlideOverVendorField source={source} isCompleted={isCompleted} vendors={vendors} />

View file

@ -16,6 +16,9 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import type { Service } from "@/domain/services/types/service";
import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge";
@ -30,6 +33,7 @@ type SlideOverInfoTabEditViewProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -114,6 +118,7 @@ export function SlideOverInfoTabEditView({
sites,
vendors,
pmTypes,
services = [],
allOrders,
onDraftChange,
onDuplicateFound,
@ -165,7 +170,18 @@ export function SlideOverInfoTabEditView({
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(type, severity) => onDraftChange({ type, severity })}
onSave={(type, severity) => {
const selectedService = services.find(
(service) => String(service.id) === String(draft.serviceId),
);
onDraftChange(
draft.serviceId != null &&
selectedService != null &&
!isServiceEligibleForWorkOrderType(selectedService, type)
? { type, severity, serviceId: null, pm: "" }
: { type, severity },
);
}}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
@ -196,14 +212,17 @@ export function SlideOverInfoTabEditView({
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
services={services}
serviceId={draft.serviceId}
workOrderType={draft.type}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm) => onDraftChange({ pm })}
onSave={(pm, id) => onDraftChange(id != null ? { pm, serviceId: id } : { pm })}
onSaveDetails={(details) =>
onDraftChange({
pm: details.pm,
...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId),
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
avetaRequired: details.avetaRequired,

View file

@ -4,6 +4,7 @@ import type {
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { Service } from "@/domain/services/types/service";
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
@ -17,6 +18,7 @@ type SlideOverInfoTabProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -33,6 +35,7 @@ export function SlideOverInfoTab({
sites,
vendors,
pmTypes,
services = [],
allOrders,
onDraftChange,
onDuplicateFound,
@ -50,6 +53,7 @@ export function SlideOverInfoTab({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
allOrders={allOrders}
onDraftChange={onDraftChange}
onDuplicateFound={onDuplicateFound}
@ -67,6 +71,7 @@ export function SlideOverInfoTab({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
/>
);
}

View file

@ -12,6 +12,7 @@ import type {
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
import type { Service } from "@/domain/services/types/service";
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
@ -25,6 +26,7 @@ type WorkOrderSlideOverTabsProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
allOrders: WorkOrderTableRow[];
commentHighlight?: CommentHighlight | null;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -40,6 +42,7 @@ export function WorkOrderSlideOverTabs({
sites,
vendors,
pmTypes,
services = [],
allOrders,
commentHighlight,
onDuplicateFound,
@ -63,6 +66,7 @@ export function WorkOrderSlideOverTabs({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}

View file

@ -12,6 +12,7 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
import type { Service } from "@/domain/services/types/service";
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -28,6 +29,7 @@ type WorkOrderSlideOverProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
@ -50,6 +52,7 @@ export function WorkOrderSlideOver({
sites,
vendors,
pmTypes,
services = [],
allOrders,
onSave,
onOpenCompletionDoc,
@ -101,6 +104,7 @@ export function WorkOrderSlideOver({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
allOrders={allOrders}
commentHighlight={commentHighlight}
onDuplicateFound={onDuplicateFound}

View file

@ -28,6 +28,7 @@ import {
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { Service } from "@/domain/services/types/service";
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
type AdvancedSearchFilterSectionsProps = {
@ -37,7 +38,8 @@ type AdvancedSearchFilterSectionsProps = {
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
sites: { id: string | number; name: string }[];
users: WorkOrderUser[];
pmTypes: string[];
services?: Service[];
pmTypes?: string[];
vendorTechs: string[];
};
@ -48,7 +50,8 @@ export function AdvancedSearchFilterSections({
setMany,
sites,
users,
pmTypes,
services = [],
pmTypes = [],
vendorTechs,
}: AdvancedSearchFilterSectionsProps) {
const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => (
@ -141,13 +144,17 @@ export function AdvancedSearchFilterSections({
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
options={
services.length > 0
? services.map((service) => ({ value: service.name, label: service.name }))
: pmTypes.map((name) => ({ value: name, label: name }))
}
selected={draft.pmTypes}
onToggle={(value) => toggleIn("pmTypes", value)}
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
placeholder="Search service…"
renderLeft={(pmType) => (
<PmTypeIcon pmType={pmType} size={14} className="shrink-0 text-muted-foreground" />
renderLeft={(name) => (
<PmTypeIcon pmType={name} size={14} className="shrink-0 text-muted-foreground" />
)}
/>
</AdvSection>

View file

@ -6,6 +6,7 @@ import { IconCloseButton } from "@/components/ui/icon-close-button";
import { SideSheet } from "@/components/ui/side-sheet";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { Service } from "@/domain/services/types/service";
type WorkOrdersAdvancedSearchSheetProps = {
open: boolean;
@ -15,7 +16,7 @@ type WorkOrdersAdvancedSearchSheetProps = {
onClearAll: () => void;
sites: { id: string | number; name: string }[];
users: WorkOrderUser[];
pmTypes: string[];
services?: Service[];
vendorTechs: string[];
};
@ -27,7 +28,7 @@ export function WorkOrdersAdvancedSearchSheet({
onClearAll,
sites,
users,
pmTypes,
services = [],
vendorTechs,
}: WorkOrdersAdvancedSearchSheetProps) {
const { draft, setDraft, activeCount, toggleIn, setMany, handleClearAll, handleApply } =
@ -59,7 +60,7 @@ export function WorkOrdersAdvancedSearchSheet({
setMany={setMany}
sites={sites}
users={users}
pmTypes={pmTypes}
services={services}
vendorTechs={vendorTechs}
/>

View file

@ -28,7 +28,7 @@ export function buildInlineDraftCreateInput(
scheduledOn: draft.scheduledOn,
dueDate: draft.dueDate,
pm: draft.pm,
serviceId: null,
serviceId: draft.serviceId ?? null,
extraServices: draft.extraServices ?? [],
avetaRequired: Boolean(draft.avetaRequired),
pmNote: draft.serviceNotes ?? "",

View file

@ -13,12 +13,15 @@ import {
hasServiceNotesDetails,
truncateServiceNotesPreview,
} from "@/domain/work-orders/utils/service-notes-ui";
import type { Service } from "@/domain/services/types/service";
import { servicesForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type PMTypeCellProps = {
value: string;
pmTypes: string[];
q?: string;
onSave: (v: string) => void;
onSave: (v: string, serviceId?: Service["id"]) => void;
serviceNotes?: string;
extraServices?: string[];
avetaRequired?: boolean;
@ -26,6 +29,9 @@ type PMTypeCellProps = {
woNumber?: string;
onSaveDetails?: (details: ServiceNotesDetails) => void;
viewOnly?: boolean;
services?: Service[];
serviceId?: string | number | null;
workOrderType?: WOType | "";
};
export function PMTypeCell({
@ -40,14 +46,22 @@ export function PMTypeCell({
woNumber,
onSaveDetails,
viewOnly = false,
services,
serviceId,
workOrderType = "",
}: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const assignableServices =
services == null ? undefined : servicesForWorkOrderType(services, workOrderType);
const serviceNames = assignableServices?.map((service) => service.name) ?? pmTypes;
const filtered = search
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
: pmTypes;
? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
: serviceNames;
const displayValue =
services?.find((service) => String(service.id) === String(serviceId))?.name ?? value;
const extras = extraServices ?? [];
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
@ -63,12 +77,14 @@ export function PMTypeCell({
<ServiceNotesDialog
open={modalOpen}
onOpenChange={setModalOpen}
pm={value}
pm={displayValue}
extraServices={extras}
serviceNotes={serviceNotes ?? ""}
avetaRequired={avetaRequired}
woNumber={woNumber}
pmTypes={pmTypes}
services={assignableServices}
serviceId={serviceId}
viewOnly={viewOnly}
onSave={onSaveDetails ?? (() => undefined)}
/>
@ -86,17 +102,17 @@ export function PMTypeCell({
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={value}
pmType={displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
{displayValue ? (
<span
className="min-w-0 truncate"
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<HighlightText text={value} q={q} />
<HighlightText text={displayValue} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
@ -112,7 +128,7 @@ export function PMTypeCell({
return (
<>
<PmTypeDetailsTrigger
value={value}
value={displayValue}
q={q}
notePreview={notePreview}
extrasCount={extras.length}
@ -136,12 +152,12 @@ export function PMTypeCell({
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={value}
pmType={displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
{displayValue ? (
<span
className="min-w-0 truncate"
style={{
@ -149,7 +165,7 @@ export function PMTypeCell({
color: "var(--color-text-primary)",
}}
>
<HighlightText text={value} q={q} />
<HighlightText text={displayValue} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
@ -161,12 +177,13 @@ export function PMTypeCell({
anchorEl={anchorEl}
search={search}
filtered={filtered}
value={value}
value={displayValue}
showAddDetails={Boolean(onSaveDetails)}
onSearchChange={setSearch}
onClose={closePopover}
onPick={(pm) => {
onSave(pm);
const picked = assignableServices?.find((service) => service.name === pm);
onSave(pm, picked?.id);
closePopover();
}}
onAddDetails={() => {

View file

@ -4,12 +4,14 @@ import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { Service } from "@/domain/services/types/service";
export type ServiceNotesDetails = {
pm: string;
extraServices: string[];
serviceNotes: string;
avetaRequired: boolean;
serviceId?: Service["id"] | null;
};
type ServiceNotesDialogProps = {
@ -21,6 +23,8 @@ type ServiceNotesDialogProps = {
avetaRequired?: boolean;
woNumber?: string;
pmTypes: string[];
services?: Service[];
serviceId?: string | number | null;
viewOnly?: boolean;
onSave: (details: ServiceNotesDetails) => void;
};
@ -34,6 +38,8 @@ export function ServiceNotesDialog({
avetaRequired = false,
woNumber,
pmTypes,
services,
serviceId,
viewOnly = false,
onSave,
}: ServiceNotesDialogProps) {
@ -56,7 +62,20 @@ export function ServiceNotesDialog({
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
};
const services = pmTypes.map((value) => ({ id: value, value }));
const serviceOptions = services
? [
...services.map((service) => ({ id: service.id, value: service.name })),
...[pm, ...extraServices]
.filter((value) => value && !services.some((service) => service.name === value))
.map((value) => ({ id: `historical:${value}`, value })),
]
: pmTypes.map((value) => ({ id: value, value }));
const saveServiceId = () => {
const selectedId = services?.find((service) => service.name === sel[0])?.id;
if (selectedId != null) return selectedId;
if (sel[0] === pm) return serviceId ?? undefined;
return serviceId == null ? undefined : null;
};
return (
<Dialog
@ -85,7 +104,7 @@ export function ServiceNotesDialog({
>
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
<WizardServiceNotesFields
services={services}
services={serviceOptions}
sel={sel}
onToggle={toggle}
note={note}
@ -121,6 +140,7 @@ export function ServiceNotesDialog({
onClick={() => {
onSave({
pm: sel[0] ?? "",
serviceId: saveServiceId(),
extraServices: sel.slice(1),
serviceNotes: note,
avetaRequired: aveta,

View file

@ -5,6 +5,8 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { Service } from "@/domain/services/types/service";
import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import {
formatSeverityEscalationToast,
shouldAnnounceSeverityEscalation,
@ -18,11 +20,18 @@ type PersistPatchFn = (
export function saveExistingRowType(
onPatch: PersistPatchFn,
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity" | "serviceId">,
type: WOType,
severity: WorkOrderSeverity | null,
services: Service[] = [],
) {
const patch = { type, severity };
const selectedService = services.find((service) => String(service.id) === String(row.serviceId));
const clearService =
row.serviceId != null &&
selectedService != null &&
selectedService.isActive &&
!isServiceEligibleForWorkOrderType(selectedService, type);
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
onPatch(row.id, patch);
return;

View file

@ -25,6 +25,7 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
completedDate: "",
dueDate: draft.dueDate,
pm: draft.pm,
serviceId: draft.serviceId,
serviceNotes: draft.serviceNotes,
extraServices: draft.extraServices,
company: draft.company,

View file

@ -8,6 +8,9 @@ import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { Service } from "@/domain/services/types/service";
import { isServiceEligibleForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
import { AssignedToCell } from "./cells/assigned-to-cell";
import { ApptTimeCell } from "./cells/appt-time-cell";
import { DateCell } from "./cells/date-cell";
@ -37,6 +40,7 @@ type WoTableInlineRowCellsProps = {
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
allRows: { id: string | number; woNumber: string }[];
siteCellRef: RefObject<HTMLTableCellElement | null>;
woCellRef: RefObject<HTMLTableCellElement | null>;
@ -61,17 +65,30 @@ function InlineFieldCell({ children }: { children: ReactNode }) {
function InlineTypeSeverityCell({
draft,
services = [],
onPatch,
}: {
draft: WorkOrderTableDraft;
services: Service[];
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) {
const selectedService = services.find(
(service) => String(service.id) === String(draft.serviceId),
);
return (
<InlineFieldCell>
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
onSave={(t, sev) =>
onPatch(
draft.serviceId != null &&
selectedService != null &&
!isServiceEligibleForWorkOrderType(selectedService, t)
? { type: t, severity: sev, serviceId: null, pm: "" }
: { type: t, severity: sev },
)
}
/>
</InlineFieldCell>
);
@ -174,6 +191,7 @@ export function WoTableInlineRowCells({
sitePlace,
vendors,
pmTypes,
services = [],
allRows,
siteCellRef,
woCellRef,
@ -202,7 +220,7 @@ export function WoTableInlineRowCells({
onDuplicateFound={onDuplicateFound}
/>
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<InlineTypeSeverityCell draft={draft} services={services} onPatch={onPatch} />
<InlineFieldCell>
<AssignedToCell
@ -227,14 +245,17 @@ export function WoTableInlineRowCells({
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
services={services}
serviceId={draft.serviceId}
workOrderType={draft.type}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm) => onPatch({ pm })}
onSave={(pm, id) => onPatch(id != null ? { pm, serviceId: id } : { pm })}
onSaveDetails={(d) =>
onPatch({
pm: d.pm,
...serviceIdentityPatch(draft.serviceId, draft.pm, d.pm, d.serviceId),
extraServices: d.extraServices,
serviceNotes: d.serviceNotes,
avetaRequired: d.avetaRequired,

View file

@ -11,6 +11,7 @@ import type {
WorkOrderTableDraft,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { Service } from "@/domain/services/types/service";
import {
UNASSIGNED_DAY_KEY,
UNSCHEDULED_DAY_KEY,
@ -33,6 +34,7 @@ type WoTableInlineRowProps = {
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
allRows: { id: string | number; woNumber: string }[];
saving?: boolean;
onChange: (draft: WorkOrderTableDraft) => void;
@ -62,6 +64,7 @@ export function WoTableInlineRow({
sitePlace,
vendors,
pmTypes,
services = [],
allRows,
saving = false,
onChange,
@ -136,6 +139,7 @@ export function WoTableInlineRow({
sitePlace={sitePlace}
vendors={vendors}
pmTypes={pmTypes}
services={services}
allRows={allRows}
siteCellRef={siteCellRef}
woCellRef={woCellRef}

View file

@ -17,12 +17,14 @@ import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell";
import type { WoTableRowHandlers } from "./wo-table-row";
import { resolveDueDateFloor } from "@/domain/work-orders/utils/due-date-floor";
import type { Service } from "@/domain/services/types/service";
type WoTableRowCellsProps = {
row: WorkOrderTableRow;
allRows: WorkOrderTableRow[];
users: WorkOrderUser[];
sites: LocationOption[];
services?: Service[];
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
@ -39,6 +41,7 @@ export function WoTableRowCells({
allRows,
users,
sites,
services = [],
sitePlace,
search,
forceScheduleHighlight,
@ -119,7 +122,7 @@ export function WoTableRowCells({
severity={row.severity}
q={search}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev, services)}
/>
</td>
<td

View file

@ -13,11 +13,14 @@ import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-di
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
import type { WoTableRowHandlers } from "./wo-table-row";
import type { Service } from "@/domain/services/types/service";
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
type WoTableRowServiceCellsProps = {
row: WorkOrderTableRow;
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
search?: string;
mutationCellStyle?: CSSProperties;
viewOnly?: boolean;
@ -30,6 +33,7 @@ export function WoTableRowServiceCells({
row,
vendors,
pmTypes,
services = [],
search,
mutationCellStyle,
viewOnly = false,
@ -49,6 +53,9 @@ export function WoTableRowServiceCells({
<PMTypeCell
value={row.pm}
pmTypes={pmTypes}
services={services}
serviceId={row.serviceId}
workOrderType={row.type}
q={search}
serviceNotes={row.serviceNotes}
extraServices={row.extraServices}
@ -56,10 +63,10 @@ export function WoTableRowServiceCells({
avetaAttached={row.hasAvetaDocument}
woNumber={row.woNumber}
viewOnly={viewOnly}
onSave={(pm) => onPatchRow({ pm })}
onSave={(pm, id) => onPatchRow(id != null ? { pm, serviceId: id } : { pm })}
onSaveDetails={(d) =>
onPatchRow({
pm: d.pm,
...serviceIdentityPatch(row.serviceId, row.pm, d.pm, d.serviceId),
extraServices: d.extraServices,
serviceNotes: d.serviceNotes,
avetaRequired: d.avetaRequired,

View file

@ -23,6 +23,7 @@ import {
resolveFirstCellShadow,
resolveWoTableRowStyle,
} from "./wo-table-row-style";
import type { Service } from "@/domain/services/types/service";
export type WoTableRowHandlers = {
onRowClick: (row: WorkOrderTableRow) => void;
@ -59,6 +60,7 @@ type WoTableRowProps = {
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
@ -101,6 +103,7 @@ export function WoTableRow({
sitePlace,
vendors,
pmTypes,
services = [],
search,
forceScheduleHighlight,
forceDueDateHighlight,
@ -197,6 +200,7 @@ export function WoTableRow({
allRows={allRows}
users={users}
sites={sites}
services={services}
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}
@ -210,6 +214,7 @@ export function WoTableRow({
<WoTableRowServiceCells
row={row}
vendors={vendors}
services={services}
pmTypes={pmTypes}
search={search}
mutationCellStyle={coreLockStyle}

View file

@ -21,6 +21,7 @@ import { WoTableInlineRow } from "./wo-table-inline-row";
import { WoTableRow } from "./wo-table-row";
import { WoTableEmptyState, WoTableShell } from "./wo-table-shell";
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
import type { Service } from "@/domain/services/types/service";
type WoTableProps = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
@ -28,6 +29,7 @@ type WoTableProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
weekMonday: string;
onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
@ -93,6 +95,7 @@ export function WoTable({
sites,
vendors,
pmTypes,
services = [],
weekMonday,
onNavigateToDay,
onOpenSlideOver,
@ -141,6 +144,7 @@ export function WoTable({
effectiveSitePlace(row) ?? resolveSitePlace(row.locationId, row.site, sitePlaceLookups)
}
vendors={vendors}
services={services}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight}
@ -165,6 +169,7 @@ export function WoTable({
sitePlaceLookups,
)}
vendors={vendors}
services={services}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={saving}

View file

@ -23,8 +23,18 @@ type WorkOrdersListPagePanelsProps = {
};
export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
page;
const {
filters,
tableData,
activeDocRow,
users,
locations,
vendors,
pmTypes,
services,
serviceHistory,
vendorTechs,
} = page;
return (
<>
@ -44,7 +54,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
services={serviceHistory}
vendorTechs={vendorTechs}
/>
@ -58,6 +68,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
services={services}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={(row) => openCompletionDoc(row, page.setDocRow)}

View file

@ -9,6 +9,10 @@ import {
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import {
useActiveServices,
useAllServices,
} from "@/domain/services/use-cases/use-services-for-work-order-type";
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
import {
@ -91,6 +95,8 @@ export function useWorkOrdersListPage() {
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const { data: services = [] } = useActiveServices();
const { data: serviceHistory = [] } = useAllServices();
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
@ -195,6 +201,8 @@ export function useWorkOrdersListPage() {
locations,
vendors,
pmTypes,
services,
serviceHistory,
vendorTechs,
activeSlideOverRow,
activeDocRow,

View file

@ -110,6 +110,7 @@ export default function WorkOrdersListPage() {
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
services={page.services}
weekMonday={filters.weekMonday}
onNavigateToDay={filters.navigateToDay}
onOpenSlideOver={page.handleOpenSlideOver}

View file

@ -22,3 +22,12 @@ export function useActiveServices(): UseQueryResult<Service[], Error> {
staleTime: 60 * 1000,
});
}
/** Registry history is available to filters so inactive historical WOs remain findable. */
export function useAllServices(): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.list(),
queryFn: () => servicesApi.getAll(),
staleTime: 60 * 1000,
});
}

View file

@ -0,0 +1,14 @@
import type { Service, ServiceWorkOrderType } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export function serviceWorkOrderType(type: WOType | ""): ServiceWorkOrderType {
return type === "Reactive" || type === "Emergency" ? type : "PM";
}
export function servicesForWorkOrderType(services: Service[], type: WOType | ""): Service[] {
return services.filter((service) => isServiceEligibleForWorkOrderType(service, type));
}
export function isServiceEligibleForWorkOrderType(service: Service, type: WOType | ""): boolean {
return service.isActive && service.supportedWorkOrderTypes.includes(serviceWorkOrderType(type));
}

View file

@ -27,6 +27,11 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
field: "severity",
toValue: (value) => (value == null ? "" : String(value)),
},
{
key: "serviceId",
field: "serviceId",
toValue: (value) => (value == null ? "" : String(value)),
},
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
{ key: "site", field: "siteCode", toValue: (value) => value as string },
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },
@ -58,6 +63,7 @@ export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[]
for (const rule of SCALAR_BOARD_OP_RULES) {
const value = patch[rule.key];
if (value === undefined) continue;
if (rule.key === "pm" && patch.serviceId !== undefined) continue;
ops.push({
field: rule.field,
value: rule.toValue(value),

View file

@ -163,6 +163,7 @@ export type WorkOrderBoardEditableField =
| "apptTime"
| "docStatus"
| "completedDate"
| "serviceId"
| "pm"
| "serviceNotes"
| "avetaRequired"

View file

@ -129,6 +129,7 @@ export interface WorkOrderTableDraft {
location: string;
locationId: string | number;
pm: string;
serviceId?: string | number | null;
serviceNotes: string;
extraServices: string[];
avetaRequired?: boolean;
@ -164,6 +165,7 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
location: "",
locationId: "",
pm: "",
serviceId: null,
serviceNotes: "",
extraServices: [],
avetaRequired: false,

View file

@ -0,0 +1,19 @@
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
export function serviceIdentityPatch(
currentServiceId: string | number | null | undefined,
currentName: string,
nextName: string,
nextServiceId: string | number | null | undefined,
): Pick<WorkOrderTablePatch, "pm" | "serviceId"> {
const sameIdentity =
currentServiceId != null &&
nextServiceId != null &&
String(currentServiceId) === String(nextServiceId);
if (sameIdentity) return {};
if (nextServiceId != null) return { pm: nextName, serviceId: nextServiceId };
if (currentServiceId != null) return { pm: nextName, serviceId: null };
return nextName === currentName ? {} : { pm: nextName };
}

View file

@ -5,6 +5,50 @@ import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/lis
import { renderWithProviders } from "@/test/test-utils";
describe("Aveta Service & Notes", () => {
it("shows eligible registry services and saves the selected service identity", () => {
const onSave = vi.fn();
renderWithProviders(
<PMTypeCell
value="Old snapshot"
serviceId={null}
pmTypes={["Leak", "No Power", "Not Cooling", "Other"]}
services={[
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
{
id: 702,
name: "Reactive Only",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["Reactive"],
},
]}
workOrderType="PM"
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i }));
expect(screen.getByRole("button", { name: "QA Test Service" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reactive Only" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "QA Test Service" }));
expect(onSave).toHaveBeenCalledWith("QA Test Service", 701);
});
it("shows the Aveta shield on the Service cell when required", () => {
renderWithProviders(
<PMTypeCell

View file

@ -4,6 +4,27 @@ import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
describe("buildInlineDraftCreateInput", () => {
it("preserves the selected registry service identity", () => {
const draft = Object.assign(
{
...EMPTY_TABLE_DRAFT,
site: "BK5",
locationId: "9",
pm: "QA Test Service",
},
{ serviceId: 701 },
);
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
expect(input.pm).toBe("QA Test Service");
expect(input.serviceId).toBe(701);
const payload = mapWizardDraftToBoardCreatePayload(input);
expect(payload.primaryService).toBe("QA Test Service");
expect(payload.serviceId).toBe(701);
});
it("defaults type to PM and omits schedule flags when undated", () => {
const draft = {
...EMPTY_TABLE_DRAFT,

View file

@ -7,6 +7,7 @@ vi.mock("react-toastify", () => ({
}));
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
import type { Service } from "@/domain/services/types/service";
const row = { id: 7, woNumber: "00000024965", severity: 3 as const };
@ -61,4 +62,49 @@ describe("saveExistingRowType", () => {
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
});
it("clears an active service before changing to an unsupported work-order type", () => {
const onPatch = vi.fn();
const services: Service[] = [
{
id: 701,
name: "PM Only Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
];
saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services);
expect(onPatch).toHaveBeenCalledWith(7, {
type: "Reactive",
severity: null,
serviceId: null,
pm: "",
});
});
it("preserves an inactive historical service on type change", () => {
const onPatch = vi.fn();
const services: Service[] = [
{
id: 701,
name: "Retired Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
];
saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Reactive", null, services);
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null });
});
});

View file

@ -102,9 +102,65 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }),
useDropdownOptionsByCategory: () => ({
data: [
{ value: "HVAC" },
{ value: "Leak" },
{ value: "No Power" },
{ value: "Not Cooling" },
{ value: "Other" },
],
}),
}));
vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/domain/services/use-cases/use-services-for-work-order-type")
>();
return {
...actual,
useActiveServices: () => ({
data: [
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
},
],
}),
useAllServices: () => ({
data: [
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
},
{
id: 702,
name: "Retired Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
],
}),
};
});
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
isWoTableMockEnabled: () => false,
}));
@ -126,6 +182,24 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
clearAdvancedFilters.mockClear();
});
it("offers active registry services instead of legacy Problem dropdown values", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
expect(within(sheet).getByText("Retired Service")).toBeInTheDocument();
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
});
it("opens the sheet from Advanced Filters, applies, then clears", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
@ -149,7 +223,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
}
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument();
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
@ -218,7 +292,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
fireEvent.click(within(sheet).getByText("HVAC"));
fireEvent.click(within(sheet).getByText("QA Test Service"));
fireEvent.click(within(sheet).getByRole("button", { name: /Completion doc/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
@ -230,13 +304,13 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(applyAdvancedFilters).toHaveBeenCalled();
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.pmTypes.has("HVAC")).toBe(true);
expect(applied.pmTypes.has("QA Test Service")).toBe(true);
expect(applied.docs.has("No")).toBe(true);
expect(applied.docs.has("Yes")).toBe(true);
expect(applied.docs.has("NN")).toBe(true);
const params = toBoardSearchQueryParams({ filters: applied });
expect(params.pmTypes).toEqual(["HVAC"]);
expect(params.pmTypes).toEqual(["QA Test Service"]);
expect(params.docStatuses).toEqual([2, 1, 3]);
});

View file

@ -65,6 +65,19 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ pm: "HVAC" })).toEqual([
{ field: "pm", value: "HVAC" },
]);
expect(expandTablePatchToBoardFieldOps({ pm: "QA Test Service", serviceId: 701 })).toEqual([
{ field: "serviceId", value: "701" },
]);
expect(expandTablePatchToBoardFieldOps({ serviceId: null, pm: "" })).toEqual([
{ field: "serviceId", value: "" },
]);
expect(
expandTablePatchToBoardFieldOps({ severity: 2, serviceId: null, type: "Reactive", pm: "" }),
).toEqual([
{ field: "severity", value: "2" },
{ field: "serviceId", value: "" },
{ field: "workOrderType", value: "Reactive" },
]);
expect(expandTablePatchToBoardFieldOps({ docStatus: "NN" })).toEqual([
{ field: "docStatus", value: "NotNeeded" },
]);

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
describe("serviceIdentityPatch", () => {
it("keeps an unchanged registry identity out of unrelated detail saves", () => {
expect(serviceIdentityPatch(701, "Old snapshot", "QA Test Service", 701)).toEqual({});
});
it("emits the registry identity when selecting a registered service", () => {
expect(serviceIdentityPatch(null, "Legacy value", "QA Test Service", 701)).toEqual({
pm: "QA Test Service",
serviceId: 701,
});
});
it("clears a registry identity when the selection is removed", () => {
expect(serviceIdentityPatch(701, "QA Test Service", "", null)).toEqual({
pm: "",
serviceId: null,
});
});
it("keeps legacy PM edits label-based", () => {
expect(serviceIdentityPatch(null, "Legacy value", "Another legacy value", undefined)).toEqual({
pm: "Another legacy value",
});
});
});