mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 08:22:06 +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(
|
const linkedService = typeServices.find(
|
||||||
(service) => String(service.id) === String(draft.serviceId),
|
(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 (
|
return (
|
||||||
<TypeCell
|
<TypeCell
|
||||||
value={draft.type}
|
value={draft.type}
|
||||||
severity={draft.severity}
|
severity={draft.severity}
|
||||||
registryUnavailable={registryUnavailable}
|
registryUnavailable={registryUnavailable}
|
||||||
|
registryMissing={linkedServiceMissing}
|
||||||
serviceHistoryQuery={serviceHistoryQuery}
|
serviceHistoryQuery={serviceHistoryQuery}
|
||||||
onSave={(type, severity) => {
|
onSave={(type, severity) => {
|
||||||
if (registryUnavailable) return;
|
if (registryUnavailable) return;
|
||||||
onDraftChange(
|
onDraftChange(
|
||||||
draft.serviceId != null &&
|
draft.serviceId != null &&
|
||||||
linkedService != null &&
|
(linkedService == null || !serviceSupportsWorkOrderType(linkedService, type))
|
||||||
!serviceSupportsWorkOrderType(linkedService, type)
|
|
||||||
? { type, severity, serviceId: null, pm: "" }
|
? { type, severity, serviceId: null, pm: "" }
|
||||||
: { type, severity },
|
: { 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 { Wrench } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
||||||
|
|
@ -16,6 +16,75 @@ export type ServiceNotesDetails = {
|
||||||
serviceId?: Service["id"] | null;
|
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 = {
|
type ServiceNotesDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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({
|
function useServiceNotesDraft({
|
||||||
open,
|
open,
|
||||||
target,
|
target,
|
||||||
|
|
@ -61,6 +220,7 @@ function useServiceNotesDraft({
|
||||||
extraServices,
|
extraServices,
|
||||||
serviceNotes,
|
serviceNotes,
|
||||||
avetaRequired,
|
avetaRequired,
|
||||||
|
services,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
target: DialogTarget;
|
target: DialogTarget;
|
||||||
|
|
@ -69,10 +229,13 @@ function useServiceNotesDraft({
|
||||||
extraServices: string[];
|
extraServices: string[];
|
||||||
serviceNotes: string;
|
serviceNotes: string;
|
||||||
avetaRequired: boolean;
|
avetaRequired: boolean;
|
||||||
|
services?: Service[];
|
||||||
}) {
|
}) {
|
||||||
const [sel, setSel] = useState<string[]>([]);
|
const [sel, setSel] = useState<string[]>([]);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [aveta, setAveta] = useState(false);
|
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 [search, setSearch] = useState("");
|
||||||
const lastTarget = useRef<DialogTarget | null>(null);
|
const lastTarget = useRef<DialogTarget | null>(null);
|
||||||
const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target;
|
const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target;
|
||||||
|
|
@ -85,6 +248,16 @@ function useServiceNotesDraft({
|
||||||
const previous = lastTarget.current;
|
const previous = lastTarget.current;
|
||||||
if (previous == null || previous.key !== targetKey) {
|
if (previous == null || previous.key !== targetKey) {
|
||||||
setSel(selectedServices(pm, extraServices));
|
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 ?? "");
|
setNote(serviceNotes ?? "");
|
||||||
setAveta(Boolean(avetaRequired));
|
setAveta(Boolean(avetaRequired));
|
||||||
setSearch("");
|
setSearch("");
|
||||||
|
|
@ -94,6 +267,7 @@ function useServiceNotesDraft({
|
||||||
previous.pm !== pm
|
previous.pm !== pm
|
||||||
) {
|
) {
|
||||||
setSel((current) => (current[0] === previous.pm ? [pm, ...current.slice(1)] : current));
|
setSel((current) => (current[0] === previous.pm ? [pm, ...current.slice(1)] : current));
|
||||||
|
setServiceIdsByName((current) => ({ ...current, [pm]: serviceId! }));
|
||||||
}
|
}
|
||||||
lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId };
|
lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId };
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -104,11 +278,25 @@ function useServiceNotesDraft({
|
||||||
pm,
|
pm,
|
||||||
serviceId,
|
serviceId,
|
||||||
extraServices,
|
extraServices,
|
||||||
|
services,
|
||||||
serviceNotes,
|
serviceNotes,
|
||||||
avetaRequired,
|
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({
|
export function ServiceNotesDialog({
|
||||||
|
|
@ -127,7 +315,20 @@ export function ServiceNotesDialog({
|
||||||
onSave,
|
onSave,
|
||||||
}: ServiceNotesDialogProps) {
|
}: ServiceNotesDialogProps) {
|
||||||
const target = getDialogTarget(woNumber, serviceId, pm);
|
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,
|
open,
|
||||||
target,
|
target,
|
||||||
pm,
|
pm,
|
||||||
|
|
@ -135,37 +336,27 @@ export function ServiceNotesDialog({
|
||||||
extraServices,
|
extraServices,
|
||||||
serviceNotes,
|
serviceNotes,
|
||||||
avetaRequired,
|
avetaRequired,
|
||||||
|
services,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggle = (value: string) => {
|
const toggle = createServiceSelectionToggle({
|
||||||
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
services,
|
||||||
};
|
selected: sel,
|
||||||
|
primaryServiceId,
|
||||||
const serviceOptions = services
|
serviceIdsByName,
|
||||||
? [
|
setSelected: setSel,
|
||||||
...services.map((service) => ({ id: service.id, value: service.name })),
|
setPrimaryServiceId,
|
||||||
...[pm, ...extraServices]
|
setServiceIdsByName,
|
||||||
.filter((value) => value && !services.some((service) => service.name === value))
|
});
|
||||||
.map((value) => ({ id: `historical:${value}`, value })),
|
const optionState = getServiceOptionState(services, serviceQuery);
|
||||||
]
|
const saveServiceId = () =>
|
||||||
: pmTypes.map((value) => ({ id: value, value }));
|
resolveSelectedServiceId({
|
||||||
const saveServiceId = () => {
|
unavailable: optionState.unavailable,
|
||||||
if (serviceOptionsUnavailable) return serviceId ?? undefined;
|
selected: sel,
|
||||||
const selectedId = services?.find((service) => service.name === sel[0])?.id;
|
currentPm: pm,
|
||||||
if (selectedId != null) return selectedId;
|
savedServiceId: serviceId,
|
||||||
if (sel[0] === pm) return serviceId ?? undefined;
|
selectedServiceId: primaryServiceId,
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -191,25 +382,24 @@ export function ServiceNotesDialog({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{ paddingTop: 8 }}
|
style={{ paddingTop: 8 }}
|
||||||
>
|
>
|
||||||
<fieldset disabled={viewOnly} className="min-w-0 space-y-4 border-0 p-0">
|
<ServiceNotesSelectionFields
|
||||||
<ServiceRegistryStatus
|
pm={pm}
|
||||||
query={serviceQuery}
|
extraServices={extraServices}
|
||||||
empty={serviceOptionsEmpty}
|
pmTypes={pmTypes}
|
||||||
emptyMessage="No services are available for this work-order type."
|
services={services}
|
||||||
/>
|
serviceQuery={serviceQuery}
|
||||||
<WizardServiceNotesFields
|
serviceOptionsUnavailable={optionState.unavailable}
|
||||||
services={serviceOptions}
|
serviceOptionsEmpty={optionState.empty}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
note={note}
|
note={note}
|
||||||
setNote={setNote}
|
setNote={setNote}
|
||||||
search={search}
|
search={search}
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
serviceSelectionDisabled={serviceOptionsUnavailable}
|
aveta={aveta}
|
||||||
avetaRequired={aveta}
|
setAveta={setAveta}
|
||||||
onAvetaRequiredChange={setAveta}
|
viewOnly={viewOnly}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
{viewOnly ? (
|
{viewOnly ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type TypeCellProps = {
|
||||||
q?: string;
|
q?: string;
|
||||||
viewOnly?: boolean;
|
viewOnly?: boolean;
|
||||||
registryUnavailable?: boolean;
|
registryUnavailable?: boolean;
|
||||||
|
registryMissing?: boolean;
|
||||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||||
};
|
};
|
||||||
|
|
@ -39,6 +40,7 @@ export function TypeCell({
|
||||||
q,
|
q,
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
registryUnavailable = false,
|
registryUnavailable = false,
|
||||||
|
registryMissing = false,
|
||||||
serviceHistoryQuery,
|
serviceHistoryQuery,
|
||||||
onSave,
|
onSave,
|
||||||
}: TypeCellProps) {
|
}: TypeCellProps) {
|
||||||
|
|
@ -127,6 +129,12 @@ export function TypeCell({
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{registryUnavailable && <ServiceRegistryStatus query={serviceHistoryQuery} unavailable />}
|
{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
|
<TypeSeverityModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,10 @@ export function saveExistingRowType({
|
||||||
const selectedService = serviceHistoryReady
|
const selectedService = serviceHistoryReady
|
||||||
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
|
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
|
||||||
: undefined;
|
: undefined;
|
||||||
if (row.serviceId != null && selectedService == null) return false;
|
|
||||||
const clearService =
|
const clearService =
|
||||||
row.serviceId != null &&
|
row.serviceId != null &&
|
||||||
selectedService != null &&
|
(selectedService == null || !serviceSupportsWorkOrderType(selectedService, type));
|
||||||
!serviceSupportsWorkOrderType(selectedService, type);
|
if (row.serviceId != null && !serviceHistoryReady) return false;
|
||||||
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
|
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
|
||||||
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||||
onPatch(row.id, patch);
|
onPatch(row.id, patch);
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,11 @@ function WoTableRowTypeCell({
|
||||||
mutationCellStyle,
|
mutationCellStyle,
|
||||||
}: WoTableRowTypeCellProps) {
|
}: WoTableRowTypeCellProps) {
|
||||||
const typeServices = serviceHistory ?? services;
|
const typeServices = serviceHistory ?? services;
|
||||||
const linkedServiceUnavailable =
|
const linkedServiceUnavailable = row.serviceId != null && !serviceHistoryReady;
|
||||||
|
const linkedServiceMissing =
|
||||||
row.serviceId != null &&
|
row.serviceId != null &&
|
||||||
(!serviceHistoryReady ||
|
serviceHistoryReady &&
|
||||||
!typeServices.some((service) => String(service.id) === String(row.serviceId)));
|
!typeServices.some((service) => String(service.id) === String(row.serviceId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
|
|
@ -80,6 +81,7 @@ function WoTableRowTypeCell({
|
||||||
q={search}
|
q={search}
|
||||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||||
registryUnavailable={linkedServiceUnavailable}
|
registryUnavailable={linkedServiceUnavailable}
|
||||||
|
registryMissing={linkedServiceMissing}
|
||||||
serviceHistoryQuery={serviceHistoryQuery}
|
serviceHistoryQuery={serviceHistoryQuery}
|
||||||
onSave={(type, severity) =>
|
onSave={(type, severity) =>
|
||||||
saveExistingRowType({
|
saveExistingRowType({
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
type WizardServiceNotesFieldsProps = {
|
type WizardServiceNotesFieldsProps = {
|
||||||
services: { id: string | number; value: string }[];
|
services: { id: string | number; value: string }[];
|
||||||
sel: string[];
|
sel: string[];
|
||||||
onToggle: (service: string) => void;
|
onToggle: (service: string, serviceId?: string | number) => void;
|
||||||
note: string;
|
note: string;
|
||||||
setNote: (v: string) => void;
|
setNote: (v: string) => void;
|
||||||
search: string;
|
search: string;
|
||||||
|
|
@ -83,7 +83,7 @@ export function WizardServiceNotesFields({
|
||||||
<button
|
<button
|
||||||
key={String(s.id)}
|
key={String(s.id)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(s.value)}
|
onClick={() => onToggle(s.value, s.id)}
|
||||||
disabled={serviceSelectionDisabled}
|
disabled={serviceSelectionDisabled}
|
||||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
|
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", () => {
|
it("shows the Aveta shield on the Service cell when required", () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<PMTypeCell
|
<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", () => {
|
it("defers a linked service type change when history has not loaded", () => {
|
||||||
const onPatch = vi.fn();
|
const onPatch = vi.fn();
|
||||||
const inactiveService: Service = {
|
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