mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 04:23:13 +00:00
fix(workorders): guard service-linked type changes
This commit is contained in:
parent
d40d4eaffb
commit
48fc415d54
20 changed files with 504 additions and 57 deletions
|
|
@ -37,6 +37,7 @@ type SlideOverInfoTabEditViewProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
|
|
@ -83,6 +84,50 @@ function SlideOverServiceField({
|
|||
);
|
||||
}
|
||||
|
||||
type SlideOverTypeFieldProps = Pick<
|
||||
SlideOverInfoTabEditViewProps,
|
||||
| "draft"
|
||||
| "services"
|
||||
| "serviceHistory"
|
||||
| "serviceHistoryReady"
|
||||
| "serviceHistoryQuery"
|
||||
| "onDraftChange"
|
||||
>;
|
||||
|
||||
function SlideOverTypeField({
|
||||
draft,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceHistoryReady = true,
|
||||
serviceHistoryQuery,
|
||||
onDraftChange,
|
||||
}: SlideOverTypeFieldProps) {
|
||||
const typeServices = serviceHistory ?? services;
|
||||
const linkedService = typeServices.find(
|
||||
(service) => String(service.id) === String(draft.serviceId),
|
||||
);
|
||||
const registryUnavailable = draft.serviceId != null && (!serviceHistoryReady || !linkedService);
|
||||
|
||||
return (
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
registryUnavailable={registryUnavailable}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
onSave={(type, severity) => {
|
||||
if (registryUnavailable) return;
|
||||
onDraftChange(
|
||||
draft.serviceId != null &&
|
||||
linkedService != null &&
|
||||
!serviceSupportsWorkOrderType(linkedService, type)
|
||||
? { type, severity, serviceId: null, pm: "" }
|
||||
: { type, severity },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SlideOverScheduleOnField({
|
||||
draft,
|
||||
onDraftChange,
|
||||
|
|
@ -164,6 +209,7 @@ export function SlideOverInfoTabEditView({
|
|||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
onDraftChange,
|
||||
|
|
@ -213,22 +259,13 @@ export function SlideOverInfoTabEditView({
|
|||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Type of WO">
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
onSave={(type, severity) => {
|
||||
const typeServices = serviceHistory ?? services;
|
||||
const selectedService = serviceHistoryReady
|
||||
? typeServices.find((service) => String(service.id) === String(draft.serviceId))
|
||||
: undefined;
|
||||
onDraftChange(
|
||||
draft.serviceId != null &&
|
||||
selectedService != null &&
|
||||
!serviceSupportsWorkOrderType(selectedService, type)
|
||||
? { type, severity, serviceId: null, pm: "" }
|
||||
: { type, severity },
|
||||
);
|
||||
}}
|
||||
<SlideOverTypeField
|
||||
draft={draft}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
onDraftChange={onDraftChange}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Assigned To">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type SlideOverInfoTabProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
|
|
@ -42,6 +43,7 @@ export function SlideOverInfoTab({
|
|||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
onDraftChange,
|
||||
|
|
@ -63,6 +65,7 @@ export function SlideOverInfoTab({
|
|||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
allOrders={allOrders}
|
||||
onDraftChange={onDraftChange}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type WorkOrderSlideOverTabsProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
commentHighlight?: CommentHighlight | null;
|
||||
|
|
@ -166,6 +167,7 @@ export function WorkOrderSlideOverTabs({
|
|||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
commentHighlight,
|
||||
|
|
@ -191,6 +193,7 @@ export function WorkOrderSlideOverTabs({
|
|||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
allOrders={allOrders}
|
||||
onDraftChange={state.handleDraftChange}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type WorkOrderSlideOverProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||
|
|
@ -59,6 +60,7 @@ export function WorkOrderSlideOver({
|
|||
services = [],
|
||||
serviceHistory,
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
allOrders,
|
||||
onSave,
|
||||
|
|
@ -114,6 +116,7 @@ export function WorkOrderSlideOver({
|
|||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
allOrders={allOrders}
|
||||
commentHighlight={commentHighlight}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ function PMTypeCellDialog({
|
|||
pmTypes={props.pmTypes}
|
||||
services={model.assignableServices}
|
||||
serviceId={props.serviceId}
|
||||
serviceQuery={props.serviceQuery}
|
||||
viewOnly={props.viewOnly ?? false}
|
||||
onSave={props.onSaveDetails ?? (() => undefined)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_componen
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { Service } from "@/domain/services/types/service";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { ServiceRegistryStatus } from "./service-registry-status";
|
||||
|
||||
export type ServiceNotesDetails = {
|
||||
pm: string;
|
||||
|
|
@ -25,6 +27,7 @@ type ServiceNotesDialogProps = {
|
|||
pmTypes: string[];
|
||||
services?: Service[];
|
||||
serviceId?: string | number | null;
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
viewOnly?: boolean;
|
||||
onSave: (details: ServiceNotesDetails) => void;
|
||||
};
|
||||
|
|
@ -40,6 +43,7 @@ export function ServiceNotesDialog({
|
|||
pmTypes,
|
||||
services,
|
||||
serviceId,
|
||||
serviceQuery,
|
||||
viewOnly = false,
|
||||
onSave,
|
||||
}: ServiceNotesDialogProps) {
|
||||
|
|
@ -71,11 +75,21 @@ export function ServiceNotesDialog({
|
|||
]
|
||||
: 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;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -103,6 +117,11 @@ export function ServiceNotesDialog({
|
|||
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}
|
||||
|
|
@ -111,6 +130,7 @@ export function ServiceNotesDialog({
|
|||
setNote={setNote}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
serviceSelectionDisabled={serviceOptionsUnavailable}
|
||||
avetaRequired={aveta}
|
||||
onAvetaRequiredChange={setAveta}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
type ServiceRegistryStatusProps = {
|
||||
query?: ServiceRegistryQueryState;
|
||||
unavailable?: boolean;
|
||||
empty?: boolean;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
export function ServiceRegistryStatus({
|
||||
query,
|
||||
unavailable = false,
|
||||
empty = false,
|
||||
emptyMessage = "No services are available.",
|
||||
}: ServiceRegistryStatusProps) {
|
||||
if (query?.isLoading && !query.hasData) {
|
||||
return <div role="status" aria-label="Loading services">Loading services…</div>;
|
||||
}
|
||||
if (query?.isError) {
|
||||
return (
|
||||
<div role="alert" aria-label="Service options unavailable">
|
||||
<QueryErrorPanel
|
||||
compact
|
||||
message="Service options couldn't load. Retry to load registry services."
|
||||
onRetry={query.retry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query?.isFetching && query.hasData) {
|
||||
return <div role="status" aria-label="Updating services">Updating services…</div>;
|
||||
}
|
||||
if (empty && query?.hasData) {
|
||||
return <div role="status">{emptyMessage}</div>;
|
||||
}
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground" role="status">
|
||||
Service history is unavailable. Retry before changing the work-order type.
|
||||
<button type="button" className="ml-1 underline" onClick={query?.retry}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -13,12 +13,16 @@ import { HoverChevron } from "./hover-chevron";
|
|||
import { SeverityBadge } from "./severity-badge";
|
||||
import { TypeSeverityModal } from "./type-severity-modal";
|
||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
import { ServiceRegistryStatus } from "./service-registry-status";
|
||||
|
||||
type TypeCellProps = {
|
||||
value: WOType | "";
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
viewOnly?: boolean;
|
||||
registryUnavailable?: boolean;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
|
|
@ -29,7 +33,15 @@ function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
|
|||
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
|
||||
}
|
||||
|
||||
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
|
||||
export function TypeCell({
|
||||
value,
|
||||
severity = null,
|
||||
q,
|
||||
viewOnly = false,
|
||||
registryUnavailable = false,
|
||||
serviceHistoryQuery,
|
||||
onSave,
|
||||
}: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||
|
|
@ -65,6 +77,7 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={registryUnavailable}
|
||||
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
|
||||
title={value ? undefined : "Select type of WO"}
|
||||
onClick={(e) => {
|
||||
|
|
@ -77,12 +90,16 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
|
|||
{Boolean(showSeverityBadge && severity != null) && (
|
||||
<SeverityBadge
|
||||
severity={severity!}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalSeed(value);
|
||||
setResetSeverity(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onClick={
|
||||
registryUnavailable
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.stopPropagation();
|
||||
setModalSeed(value);
|
||||
setResetSeverity(false);
|
||||
setModalOpen(true);
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HoverChevron />
|
||||
|
|
@ -109,6 +126,8 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
|
|||
))}
|
||||
</Popover>
|
||||
|
||||
{registryUnavailable && <ServiceRegistryStatus query={serviceHistoryQuery} unavailable />}
|
||||
|
||||
<TypeSeverityModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,11 @@ export function saveExistingRowType({
|
|||
severity,
|
||||
serviceHistory = [],
|
||||
serviceHistoryReady = true,
|
||||
}: SaveExistingRowTypeOptions) {
|
||||
}: SaveExistingRowTypeOptions): boolean {
|
||||
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 &&
|
||||
|
|
@ -45,9 +46,10 @@ export function saveExistingRowType({
|
|||
const patch = clearService ? { type, severity, serviceId: null, pm: "" } : { type, severity };
|
||||
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||
onPatch(row.id, patch);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
onPatch(row.id, patch, {
|
||||
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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";
|
||||
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
|
||||
|
||||
type WoTableRowCellsProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -27,6 +28,7 @@ type WoTableRowCellsProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceHistoryReady?: boolean;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
sitePlace?: { city: string; state: string };
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
|
|
@ -38,6 +40,62 @@ type WoTableRowCellsProps = {
|
|||
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
};
|
||||
|
||||
type WoTableRowTypeCellProps = Pick<
|
||||
WoTableRowCellsProps,
|
||||
| "row"
|
||||
| "services"
|
||||
| "serviceHistory"
|
||||
| "serviceHistoryReady"
|
||||
| "serviceHistoryQuery"
|
||||
| "search"
|
||||
| "handlers"
|
||||
| "mutationCellStyle"
|
||||
>;
|
||||
|
||||
function WoTableRowTypeCell({
|
||||
row,
|
||||
services = [],
|
||||
serviceHistory,
|
||||
serviceHistoryReady = true,
|
||||
serviceHistoryQuery,
|
||||
search,
|
||||
handlers,
|
||||
mutationCellStyle,
|
||||
}: WoTableRowTypeCellProps) {
|
||||
const typeServices = serviceHistory ?? services;
|
||||
const linkedServiceUnavailable =
|
||||
row.serviceId != null &&
|
||||
(!serviceHistoryReady ||
|
||||
!typeServices.some((service) => String(service.id) === String(row.serviceId)));
|
||||
|
||||
return (
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<TypeCell
|
||||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
registryUnavailable={linkedServiceUnavailable}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
onSave={(type, severity) =>
|
||||
saveExistingRowType({
|
||||
onPatch: handlers.onPatch,
|
||||
row,
|
||||
type,
|
||||
severity,
|
||||
serviceHistory: typeServices,
|
||||
serviceHistoryReady,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoTableRowCells({
|
||||
row,
|
||||
allRows,
|
||||
|
|
@ -46,6 +104,7 @@ export function WoTableRowCells({
|
|||
services = [],
|
||||
serviceHistory,
|
||||
serviceHistoryReady = true,
|
||||
serviceHistoryQuery,
|
||||
sitePlace,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
|
|
@ -116,28 +175,16 @@ export function WoTableRowCells({
|
|||
onDuplicateFound={handlers.onDuplicateFound}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TypeCell
|
||||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
onSave={(t, sev) =>
|
||||
saveExistingRowType({
|
||||
onPatch: handlers.onPatch,
|
||||
row,
|
||||
type: t,
|
||||
severity: sev,
|
||||
serviceHistory: serviceHistory ?? services,
|
||||
serviceHistoryReady,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<WoTableRowTypeCell
|
||||
row={row}
|
||||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
search={search}
|
||||
handlers={handlers}
|
||||
mutationCellStyle={mutationCellStyle}
|
||||
/>
|
||||
<td
|
||||
className="group/cell"
|
||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ type WoTableRowProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
search?: string;
|
||||
forceScheduleHighlight?: boolean;
|
||||
|
|
@ -110,6 +111,7 @@ export function WoTableRow({
|
|||
services = [],
|
||||
serviceHistory = [],
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
search,
|
||||
forceScheduleHighlight,
|
||||
|
|
@ -210,6 +212,7 @@ export function WoTableRow({
|
|||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
sitePlace={sitePlace}
|
||||
search={search}
|
||||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type WoTableProps = {
|
|||
services?: Service[];
|
||||
serviceHistory?: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady?: boolean;
|
||||
weekMonday: string;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
|
|
@ -103,6 +104,7 @@ type WoTableRowRenderProps = {
|
|||
services: Service[];
|
||||
serviceHistory: Service[];
|
||||
serviceQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryQuery?: ServiceRegistryQueryState;
|
||||
serviceHistoryReady: boolean;
|
||||
pmTypes: string[];
|
||||
search: string;
|
||||
|
|
@ -208,6 +210,7 @@ export function WoTable({
|
|||
services = [],
|
||||
serviceHistory = [],
|
||||
serviceQuery,
|
||||
serviceHistoryQuery,
|
||||
serviceHistoryReady = true,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
|
|
@ -260,6 +263,7 @@ export function WoTable({
|
|||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={serviceQuery}
|
||||
serviceHistoryQuery={serviceHistoryQuery}
|
||||
serviceHistoryReady={serviceHistoryReady}
|
||||
pmTypes={pmTypes}
|
||||
search={tableData.search}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
|
|||
services={services}
|
||||
serviceHistory={serviceHistory}
|
||||
serviceQuery={servicePickerState}
|
||||
serviceHistoryQuery={serviceHistoryState}
|
||||
serviceHistoryReady={serviceHistoryReadyForTypeChange}
|
||||
allOrders={tableData.rows}
|
||||
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type WizardServiceNotesFieldsProps = {
|
|||
setSearch: (v: string) => void;
|
||||
avetaRequired?: boolean;
|
||||
onAvetaRequiredChange?: (value: boolean) => void;
|
||||
serviceSelectionDisabled?: boolean;
|
||||
};
|
||||
|
||||
function SvcSectionLabel({ children }: { children: ReactNode }) {
|
||||
|
|
@ -48,6 +49,7 @@ export function WizardServiceNotesFields({
|
|||
setSearch,
|
||||
avetaRequired = false,
|
||||
onAvetaRequiredChange,
|
||||
serviceSelectionDisabled = false,
|
||||
}: WizardServiceNotesFieldsProps) {
|
||||
const filtered = search
|
||||
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
|
||||
|
|
@ -61,6 +63,7 @@ export function WizardServiceNotesFields({
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search services…"
|
||||
disabled={serviceSelectionDisabled}
|
||||
className="mb-1.5 w-full rounded px-2 py-1.5 text-[12px] focus:outline-none focus:ring-1"
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
|
|
@ -81,6 +84,7 @@ export function WizardServiceNotesFields({
|
|||
key={String(s.id)}
|
||||
type="button"
|
||||
onClick={() => onToggle(s.value)}
|
||||
disabled={serviceSelectionDisabled}
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -23,11 +23,7 @@ export function useWorkOrderServiceRegistry() {
|
|||
const historyQuery = useAllServices();
|
||||
const servicePickerState = toServiceRegistryQueryState(activeQuery);
|
||||
const serviceHistoryState = toServiceRegistryQueryState(historyQuery);
|
||||
const serviceHistoryReadyForTypeChange =
|
||||
serviceHistoryState.hasData &&
|
||||
!serviceHistoryState.isLoading &&
|
||||
!serviceHistoryState.isError &&
|
||||
!serviceHistoryState.isFetching;
|
||||
const serviceHistoryReadyForTypeChange = serviceHistoryState.hasData;
|
||||
|
||||
return {
|
||||
services: activeQuery.data ?? [],
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export default function WorkOrdersListPage() {
|
|||
services={page.services}
|
||||
serviceHistory={page.serviceHistory}
|
||||
serviceQuery={page.servicePickerState}
|
||||
serviceHistoryQuery={page.serviceHistoryState}
|
||||
serviceHistoryReady={page.serviceHistoryReadyForTypeChange}
|
||||
weekMonday={filters.weekMonday}
|
||||
onNavigateToDay={filters.navigateToDay}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,41 @@
|
|||
import { useState } from "react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||
import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog";
|
||||
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
describe("Aveta Service & Notes", () => {
|
||||
it("defers linked type selection with visible loading and retry feedback", () => {
|
||||
const retry = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const query = {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
hasData: false,
|
||||
retry,
|
||||
};
|
||||
renderWithProviders(
|
||||
<TypeCell
|
||||
value="PM"
|
||||
severity={null}
|
||||
registryUnavailable
|
||||
serviceHistoryQuery={query}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Type of WO, PM/i })).toBeDisabled();
|
||||
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading and preserves the current value while registry services are pending", () => {
|
||||
renderWithProviders(
|
||||
<PMTypeCell
|
||||
|
|
@ -300,4 +330,169 @@ describe("Aveta Service & Notes", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows pending and successful-empty registry states without blocking notes", () => {
|
||||
const initial = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
pm: "Saved service",
|
||||
extraServices: [],
|
||||
serviceNotes: "Saved notes",
|
||||
pmTypes: ["Leak"],
|
||||
services: [] as {
|
||||
id: number;
|
||||
name: string;
|
||||
trade: string;
|
||||
iconKey: string;
|
||||
requiresCompletionDocument: boolean;
|
||||
completionDocTemplate: null;
|
||||
isActive: boolean;
|
||||
supportedWorkOrderTypes: "PM"[];
|
||||
}[],
|
||||
serviceId: 701,
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const view = renderWithProviders(
|
||||
<ServiceNotesDialog
|
||||
{...initial}
|
||||
serviceQuery={{
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
hasData: false,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled();
|
||||
|
||||
view.rerender(
|
||||
<ServiceNotesDialog
|
||||
{...initial}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/no services are available for this work-order type/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toBeEnabled();
|
||||
});
|
||||
|
||||
it.each(["picker add-details", "existing details trigger"])(
|
||||
"shows registry retry and keeps notes editable from the %s entry",
|
||||
(entry) => {
|
||||
const onRetry = vi.fn();
|
||||
const onSaveDetails = vi.fn();
|
||||
const props = {
|
||||
value: "Saved service",
|
||||
pmTypes: ["Leak"],
|
||||
services: [],
|
||||
serviceId: 701,
|
||||
serviceNotes: entry === "picker add-details" ? "" : "Original notes",
|
||||
extraServices: [],
|
||||
onSave: vi.fn(),
|
||||
onSaveDetails,
|
||||
serviceQuery: {
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
hasData: false,
|
||||
retry: onRetry,
|
||||
},
|
||||
};
|
||||
renderWithProviders(<PMTypeCell {...props} />, { withAuth: false });
|
||||
|
||||
if (entry === "picker add-details") {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getByRole("alert", { name: /service options unavailable/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
const notes = screen.getByPlaceholderText(/describe the specific issue/i);
|
||||
expect(notes).toBeEnabled();
|
||||
fireEvent.change(notes, { target: { value: "Updated notes" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSaveDetails).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pm: "Saved service",
|
||||
serviceId: 701,
|
||||
serviceNotes: "Updated notes",
|
||||
}),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("allows registry selection after retry resolves and saves the service ID", () => {
|
||||
const onSave = vi.fn();
|
||||
function RetryHarness() {
|
||||
const [resolved, setResolved] = useState(false);
|
||||
return (
|
||||
<ServiceNotesDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
pm="Saved service"
|
||||
extraServices={[]}
|
||||
serviceNotes=""
|
||||
pmTypes={["Leak"]}
|
||||
services={
|
||||
resolved
|
||||
? [
|
||||
{
|
||||
id: 702,
|
||||
name: "QA Test Service",
|
||||
trade: "Electrical",
|
||||
iconKey: "wrench",
|
||||
requiresCompletionDocument: false,
|
||||
completionDocTemplate: null,
|
||||
isActive: true,
|
||||
supportedWorkOrderTypes: ["PM"],
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
serviceId={701}
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: !resolved,
|
||||
isFetching: false,
|
||||
hasData: resolved,
|
||||
retry: () => setResolved(true),
|
||||
}}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderWithProviders(<RetryHarness />, { withAuth: false });
|
||||
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/search services/i)).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(screen.getByPlaceholderText(/search services/i)).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Saved servicePrimary/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /QA Test Service/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ describe("saveExistingRowType", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves a service when the registry history has not loaded yet", () => {
|
||||
it("defers a linked service type change when history has not loaded", () => {
|
||||
const onPatch = vi.fn();
|
||||
const inactiveService: Service = {
|
||||
id: 701,
|
||||
|
|
@ -173,6 +173,20 @@ describe("saveExistingRowType", () => {
|
|||
serviceHistoryReady: false,
|
||||
});
|
||||
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows type changes for unlinked legacy rows while history is unavailable", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType({
|
||||
onPatch,
|
||||
row: { ...row, serviceId: null },
|
||||
type: "Emergency",
|
||||
severity: null,
|
||||
serviceHistoryReady: false,
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -343,10 +343,7 @@ describe("SlideOverInfoTabEditView additional contacts", () => {
|
|||
fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith([
|
||||
{ field: "severity", value: "2" },
|
||||
{ field: "workOrderType", value: "Emergency" },
|
||||
]);
|
||||
expect(onSave).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("passes additionalContacts into SiteCell and persists them on save", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryFixtures = vi.hoisted(() => ({ active: null as unknown, history: null as unknown }));
|
||||
|
||||
vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", () => ({
|
||||
useActiveServices: () => queryFixtures.active,
|
||||
useAllServices: () => queryFixtures.history,
|
||||
}));
|
||||
|
||||
import { useWorkOrderServiceRegistry } from "@/app/(protected)/workorders/_hooks/use-work-order-service-registry";
|
||||
|
||||
function query(data: unknown, isFetching: boolean) {
|
||||
return {
|
||||
data,
|
||||
isPending: data === undefined,
|
||||
isError: false,
|
||||
isFetching,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useWorkOrderServiceRegistry", () => {
|
||||
beforeEach(() => {
|
||||
queryFixtures.active = query([], false);
|
||||
queryFixtures.history = query([], false);
|
||||
});
|
||||
|
||||
it("uses cached service history while a background refresh is in progress", () => {
|
||||
const cached = [{ id: 701, name: "Cached historical service" }];
|
||||
queryFixtures.history = query(cached, true);
|
||||
|
||||
const { result } = renderHook(() => useWorkOrderServiceRegistry());
|
||||
|
||||
expect(result.current.serviceHistory).toEqual(cached);
|
||||
expect(result.current.serviceHistoryReadyForTypeChange).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat initial pending or failed queries without data as ready", () => {
|
||||
queryFixtures.history = { ...query(undefined, true), isPending: true };
|
||||
const pending = renderHook(() => useWorkOrderServiceRegistry());
|
||||
expect(pending.result.current.serviceHistoryReadyForTypeChange).toBe(false);
|
||||
pending.unmount();
|
||||
|
||||
queryFixtures.history = { ...query(undefined, false), isError: true, isPending: false };
|
||||
const failed = renderHook(() => useWorkOrderServiceRegistry());
|
||||
expect(failed.result.current.serviceHistoryReadyForTypeChange).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue