fix(workorders): preserve service identity and recover missing links

This commit is contained in:
Alexandre Brandizzi 2026-09-30 20:33:39 -03:00
parent 5ee2f6f374
commit bb8d56d334
9 changed files with 511 additions and 62 deletions

View file

@ -106,20 +106,22 @@ function SlideOverTypeField({
const linkedService = typeServices.find(
(service) => String(service.id) === String(draft.serviceId),
);
const registryUnavailable = draft.serviceId != null && (!serviceHistoryReady || !linkedService);
const registryUnavailable = draft.serviceId != null && !serviceHistoryReady;
const linkedServiceMissing =
draft.serviceId != null && serviceHistoryReady && linkedService == null;
return (
<TypeCell
value={draft.type}
severity={draft.severity}
registryUnavailable={registryUnavailable}
registryMissing={linkedServiceMissing}
serviceHistoryQuery={serviceHistoryQuery}
onSave={(type, severity) => {
if (registryUnavailable) return;
onDraftChange(
draft.serviceId != null &&
linkedService != null &&
!serviceSupportsWorkOrderType(linkedService, type)
(linkedService == null || !serviceSupportsWorkOrderType(linkedService, type))
? { type, severity, serviceId: null, pm: "" }
: { type, severity },
);

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type SetStateAction } from "react";
import { Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
@ -16,6 +16,75 @@ export type ServiceNotesDetails = {
serviceId?: Service["id"] | null;
};
type ServiceNotesSelectionFieldsProps = {
pm: string;
extraServices: string[];
pmTypes: string[];
services?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceOptionsUnavailable: boolean;
serviceOptionsEmpty: boolean;
sel: string[];
onToggle: (value: string, optionId?: string | number) => void;
note: string;
setNote: (value: string) => void;
search: string;
setSearch: (value: string) => void;
aveta: boolean;
setAveta: (value: boolean) => void;
viewOnly: boolean;
};
function ServiceNotesSelectionFields({
pm,
extraServices,
pmTypes,
services,
serviceQuery,
serviceOptionsUnavailable,
serviceOptionsEmpty,
sel,
onToggle,
note,
setNote,
search,
setSearch,
aveta,
setAveta,
viewOnly,
}: ServiceNotesSelectionFieldsProps) {
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 }));
return (
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
<ServiceRegistryStatus
query={serviceQuery}
empty={serviceOptionsEmpty}
emptyMessage="No services are available for this work-order type."
/>
<WizardServiceNotesFields
services={serviceOptions}
sel={sel}
onToggle={onToggle}
note={note}
setNote={setNote}
search={search}
setSearch={setSearch}
serviceSelectionDisabled={serviceOptionsUnavailable}
avetaRequired={aveta}
onAvetaRequiredChange={setAveta}
/>
</fieldset>
);
}
type ServiceNotesDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -53,6 +122,96 @@ function selectedServices(pm: string, extraServices: string[]) {
);
}
function createServiceSelectionToggle({
services,
selected,
primaryServiceId,
serviceIdsByName,
setSelected,
setPrimaryServiceId,
setServiceIdsByName,
}: {
services?: Service[];
selected: string[];
primaryServiceId?: string | number | null;
serviceIdsByName: Record<string, string | number>;
setSelected: (value: string[]) => void;
setPrimaryServiceId: (value: string | number | null | undefined) => void;
setServiceIdsByName: (value: SetStateAction<Record<string, string | number>>) => void;
}) {
return (value: string, optionId?: string | number) => {
const option = services?.find((service) => String(service.id) === String(optionId));
const optionServiceId = option?.id;
const replacesSameLabel =
selected[0] === value &&
optionServiceId != null &&
String(optionServiceId) !== String(primaryServiceId);
if (replacesSameLabel) {
setPrimaryServiceId(optionServiceId);
setServiceIdsByName((current) => ({ ...current, [value]: optionServiceId }));
return;
}
if (selected.includes(value)) {
const next = selected.filter((item) => item !== value);
setSelected(next);
if (selected[0] === value) {
const nextName = next[0];
const nextService = services?.find((service) => service.name === nextName);
setPrimaryServiceId(
nextName == null ? undefined : (serviceIdsByName[nextName] ?? nextService?.id),
);
}
return;
}
setSelected([...selected, value]);
if (optionServiceId != null) {
setServiceIdsByName((current) => ({ ...current, [value]: optionServiceId }));
if (selected.length === 0) setPrimaryServiceId(optionServiceId);
}
};
}
function resolveSelectedServiceId({
unavailable,
selected,
currentPm,
savedServiceId,
selectedServiceId,
}: {
unavailable: boolean;
selected: string[];
currentPm: string;
savedServiceId?: string | number | null;
selectedServiceId?: string | number | null;
}) {
if (unavailable) return savedServiceId ?? undefined;
if (
selected[0] === currentPm &&
String(savedServiceId ?? "") === String(selectedServiceId ?? "")
) {
return savedServiceId ?? undefined;
}
if (selected[0] != null && selectedServiceId != null) return selectedServiceId;
if (selected[0] === currentPm && savedServiceId != null) return savedServiceId;
return savedServiceId == null ? undefined : null;
}
function getServiceOptionState(
services: Service[] | undefined,
query: ServiceRegistryQueryState | undefined,
) {
const managed = services !== undefined;
return {
unavailable: managed && query != null && !query.hasData,
empty:
managed &&
query?.hasData === true &&
!query.isLoading &&
!query.isError &&
services.length === 0,
};
}
function useServiceNotesDraft({
open,
target,
@ -61,6 +220,7 @@ function useServiceNotesDraft({
extraServices,
serviceNotes,
avetaRequired,
services,
}: {
open: boolean;
target: DialogTarget;
@ -69,10 +229,13 @@ function useServiceNotesDraft({
extraServices: string[];
serviceNotes: string;
avetaRequired: boolean;
services?: Service[];
}) {
const [sel, setSel] = useState<string[]>([]);
const [note, setNote] = useState("");
const [aveta, setAveta] = useState(false);
const [primaryServiceId, setPrimaryServiceId] = useState<string | number | null | undefined>();
const [serviceIdsByName, setServiceIdsByName] = useState<Record<string, string | number>>({});
const [search, setSearch] = useState("");
const lastTarget = useRef<DialogTarget | null>(null);
const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target;
@ -85,6 +248,16 @@ function useServiceNotesDraft({
const previous = lastTarget.current;
if (previous == null || previous.key !== targetKey) {
setSel(selectedServices(pm, extraServices));
setPrimaryServiceId(serviceId);
setServiceIdsByName({
...(serviceId != null ? { [pm]: serviceId } : {}),
...Object.fromEntries(
extraServices.flatMap((name) => {
const service = services?.find((item) => item.name === name);
return service == null ? [] : [[name, service.id]];
}),
),
});
setNote(serviceNotes ?? "");
setAveta(Boolean(avetaRequired));
setSearch("");
@ -94,6 +267,7 @@ function useServiceNotesDraft({
previous.pm !== pm
) {
setSel((current) => (current[0] === previous.pm ? [pm, ...current.slice(1)] : current));
setServiceIdsByName((current) => ({ ...current, [pm]: serviceId! }));
}
lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId };
}, [
@ -104,11 +278,25 @@ function useServiceNotesDraft({
pm,
serviceId,
extraServices,
services,
serviceNotes,
avetaRequired,
]);
return { sel, setSel, note, setNote, aveta, setAveta, search, setSearch };
return {
sel,
setSel,
note,
setNote,
aveta,
setAveta,
search,
setSearch,
primaryServiceId,
setPrimaryServiceId,
serviceIdsByName,
setServiceIdsByName,
};
}
export function ServiceNotesDialog({
@ -127,7 +315,20 @@ export function ServiceNotesDialog({
onSave,
}: ServiceNotesDialogProps) {
const target = getDialogTarget(woNumber, serviceId, pm);
const { sel, setSel, note, setNote, aveta, setAveta, search, setSearch } = useServiceNotesDraft({
const {
sel,
setSel,
note,
setNote,
aveta,
setAveta,
search,
setSearch,
primaryServiceId,
setPrimaryServiceId,
serviceIdsByName,
setServiceIdsByName,
} = useServiceNotesDraft({
open,
target,
pm,
@ -135,37 +336,27 @@ export function ServiceNotesDialog({
extraServices,
serviceNotes,
avetaRequired,
services,
});
const toggle = (value: string) => {
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, 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 = () => {
if (serviceOptionsUnavailable) return serviceId ?? undefined;
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;
};
const registryManaged = services !== undefined;
const serviceOptionsUnavailable =
registryManaged && serviceQuery != null && !serviceQuery.hasData;
const serviceOptionsEmpty =
registryManaged &&
serviceQuery?.hasData === true &&
!serviceQuery.isLoading &&
!serviceQuery.isError &&
services.length === 0;
const toggle = createServiceSelectionToggle({
services,
selected: sel,
primaryServiceId,
serviceIdsByName,
setSelected: setSel,
setPrimaryServiceId,
setServiceIdsByName,
});
const optionState = getServiceOptionState(services, serviceQuery);
const saveServiceId = () =>
resolveSelectedServiceId({
unavailable: optionState.unavailable,
selected: sel,
currentPm: pm,
savedServiceId: serviceId,
selectedServiceId: primaryServiceId,
});
return (
<Dialog
open={open}
@ -191,25 +382,24 @@ export function ServiceNotesDialog({
onClick={(e) => e.stopPropagation()}
style={{ paddingTop: 8 }}
>
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
<ServiceRegistryStatus
query={serviceQuery}
empty={serviceOptionsEmpty}
emptyMessage="No services are available for this work-order type."
/>
<WizardServiceNotesFields
services={serviceOptions}
sel={sel}
onToggle={toggle}
note={note}
setNote={setNote}
search={search}
setSearch={setSearch}
serviceSelectionDisabled={serviceOptionsUnavailable}
avetaRequired={aveta}
onAvetaRequiredChange={setAveta}
/>
</fieldset>
<ServiceNotesSelectionFields
pm={pm}
extraServices={extraServices}
pmTypes={pmTypes}
services={services}
serviceQuery={serviceQuery}
serviceOptionsUnavailable={optionState.unavailable}
serviceOptionsEmpty={optionState.empty}
sel={sel}
onToggle={toggle}
note={note}
setNote={setNote}
search={search}
setSearch={setSearch}
aveta={aveta}
setAveta={setAveta}
viewOnly={viewOnly}
/>
<div className="mt-2 flex items-center justify-end gap-2">
{viewOnly ? (
<Button

View file

@ -22,6 +22,7 @@ type TypeCellProps = {
q?: string;
viewOnly?: boolean;
registryUnavailable?: boolean;
registryMissing?: boolean;
serviceHistoryQuery?: ServiceRegistryQueryState;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
@ -39,6 +40,7 @@ export function TypeCell({
q,
viewOnly = false,
registryUnavailable = false,
registryMissing = false,
serviceHistoryQuery,
onSave,
}: TypeCellProps) {
@ -127,6 +129,12 @@ export function TypeCell({
</Popover>
{registryUnavailable && <ServiceRegistryStatus query={serviceHistoryQuery} unavailable />}
{registryMissing && (
<div className="text-xs text-muted-foreground" role="status">
This linked service is no longer in the registry. Changing type clears its assignment; add
a replacement in Service &amp; Notes if needed.
</div>
)}
<TypeSeverityModal
open={modalOpen}

View file

@ -38,11 +38,10 @@ export function saveExistingRowType({
const selectedService = serviceHistoryReady
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
: undefined;
if (row.serviceId != null && selectedService == null) return false;
const clearService =
row.serviceId != null &&
selectedService != null &&
!serviceSupportsWorkOrderType(selectedService, type);
(selectedService == null || !serviceSupportsWorkOrderType(selectedService, type));
if (row.serviceId != null && !serviceHistoryReady) return false;
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
onPatch(row.id, patch);

View file

@ -63,10 +63,11 @@ function WoTableRowTypeCell({
mutationCellStyle,
}: WoTableRowTypeCellProps) {
const typeServices = serviceHistory ?? services;
const linkedServiceUnavailable =
const linkedServiceUnavailable = row.serviceId != null && !serviceHistoryReady;
const linkedServiceMissing =
row.serviceId != null &&
(!serviceHistoryReady ||
!typeServices.some((service) => String(service.id) === String(row.serviceId)));
serviceHistoryReady &&
!typeServices.some((service) => String(service.id) === String(row.serviceId));
return (
<td
@ -80,6 +81,7 @@ function WoTableRowTypeCell({
q={search}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
registryUnavailable={linkedServiceUnavailable}
registryMissing={linkedServiceMissing}
serviceHistoryQuery={serviceHistoryQuery}
onSave={(type, severity) =>
saveExistingRowType({

View file

@ -10,7 +10,7 @@ import {
type WizardServiceNotesFieldsProps = {
services: { id: string | number; value: string }[];
sel: string[];
onToggle: (service: string) => void;
onToggle: (service: string, serviceId?: string | number) => void;
note: string;
setNote: (v: string) => void;
search: string;
@ -83,7 +83,7 @@ export function WizardServiceNotesFields({
<button
key={String(s.id)}
type="button"
onClick={() => onToggle(s.value)}
onClick={() => onToggle(s.value, s.id)}
disabled={serviceSelectionDisabled}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
>

View file

@ -333,6 +333,76 @@ describe("Aveta Service & Notes", () => {
);
});
it("keeps a historical service ID when an active service reuses its label", () => {
const onSave = vi.fn();
renderWithProviders(
<ServiceNotesDialog
open
onOpenChange={vi.fn()}
pm="Retired Service"
serviceId={1}
extraServices={[]}
serviceNotes="Saved notes"
pmTypes={[]}
services={[serviceDefinition(2, "Retired Service")]}
serviceQuery={{
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
}}
onSave={onSave}
/>,
{ withAuth: false },
);
fireEvent.change(screen.getByPlaceholderText(/describe the specific issue/i), {
target: { value: "Edited notes only" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
serviceId: 1,
pm: "Retired Service",
serviceNotes: "Edited notes only",
}),
);
});
it("replaces a colliding historical service only after explicit same-label selection", () => {
const onSave = vi.fn();
renderWithProviders(
<ServiceNotesDialog
open
onOpenChange={vi.fn()}
pm="Retired Service"
serviceId={1}
extraServices={[]}
serviceNotes="Saved notes"
pmTypes={[]}
services={[serviceDefinition(2, "Retired Service")]}
serviceQuery={{
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
}}
onSave={onSave}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /Retired ServicePrimary/i }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ serviceId: 2, pm: "Retired Service" }),
);
});
it("shows the Aveta shield on the Service cell when required", () => {
renderWithProviders(
<PMTypeCell

View file

@ -151,6 +151,25 @@ describe("saveExistingRowType", () => {
});
});
it("clears a settled missing service before changing type", () => {
const onPatch = vi.fn();
const saved = saveExistingRowType({
onPatch,
row: { ...row, serviceId: 799 },
type: "Emergency",
severity: 2,
serviceHistory: [],
serviceHistoryReady: true,
});
expect(saved).toBe(true);
expect(onPatch.mock.calls[0]?.slice(0, 2)).toEqual([
7,
{ type: "Emergency", severity: 2, serviceId: null, pm: "" },
]);
});
it("defers a linked service type change when history has not loaded", () => {
const onPatch = vi.fn();
const inactiveService: Service = {

View file

@ -0,0 +1,159 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
SiteCell: () => null,
}));
vi.mock(
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
() => ({
EditableWONumberCell: () => null,
}),
);
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
AssignedToCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
DateCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
DueDateCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
ApptTimeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
VendorCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
StatusCell: () => null,
}));
function row(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 7,
woNumber: "WO-7",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",
location: "Seattle",
locationId: "loc-1",
pm: "Deleted service",
serviceId: 799,
serviceNotes: "Keep these notes",
extraServices: [],
company: "Vendor",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
isAddOn: false,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "row-v1",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
function chooseEmergency() {
fireEvent.click(screen.getByRole("button", { name: "Type of WO, PM" }));
fireEvent.click(screen.getByRole("button", { name: "Emergency" }));
fireEvent.click(screen.getByRole("button", { name: /SEV 2/ }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
}
describe("settled missing service type recovery", () => {
it("offers an enabled table type change and clears only the missing service link", () => {
const onPatch = vi.fn();
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableRowCells
row={row()}
allRows={[]}
users={[]}
sites={[]}
serviceHistory={[]}
serviceHistoryReady
handlers={{ onPatch } as unknown as WoTableRowHandlers}
onPatchRow={vi.fn()}
/>
</tr>
</tbody>
</table>,
{ withAuth: false },
);
const typeButton = screen.getByRole("button", { name: "Type of WO, PM" });
expect(typeButton).toBeEnabled();
expect(screen.getByText(/no longer in the registry/i)).toBeInTheDocument();
chooseEmergency();
expect(onPatch.mock.calls[0]?.slice(0, 2)).toEqual([
7,
{ type: "Emergency", severity: 2, serviceId: null, pm: "" },
]);
});
it("lets slide-over users recover while retaining notes and other identity fields", () => {
const onDraftChange = vi.fn();
renderWithProviders(
<SlideOverInfoTabEditView
draft={row()}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
services={[]}
serviceHistory={[]}
serviceHistoryReady
allOrders={[]}
onDraftChange={onDraftChange}
/>,
{ withAuth: false },
);
expect(screen.getByText(/no longer in the registry/i)).toBeInTheDocument();
chooseEmergency();
expect(onDraftChange).toHaveBeenCalledWith({
type: "Emergency",
severity: 2,
serviceId: null,
pm: "",
});
expect(onDraftChange.mock.calls[0]?.[0]).not.toHaveProperty("serviceNotes");
});
});