mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 04:33:23 +00:00
fix(workorders): preserve service identity and recover missing links
This commit is contained in:
parent
5ee2f6f374
commit
bb8d56d334
9 changed files with 511 additions and 62 deletions
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & Notes if needed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TypeSeverityModal
|
||||
open={modalOpen}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue