Merge pull request #287 from Sea-Haven-Industries/fix/ab/sh413-services-registry-thr_9fpik7tdmw
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions

SH-413: use registry Services in work-order assignment and filters
This commit is contained in:
Alexandre Brandizzi 2026-10-01 00:05:14 +00:00 • committed by GitHub
commit 9fbc166d2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3669 additions and 398 deletions

View file

@ -12,6 +12,19 @@ const TEST_USER = {
id: "1",
};
const SERVICE_CATALOG = [
{
id: 1,
name: "HVAC",
trade: "HVAC",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
];
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
}
@ -39,6 +52,9 @@ async function mockWorkOrderApis(page: Page, searchUrls: string[]) {
if (pathname.includes("/workorders/board") || pathname.includes("DropdownOptions")) {
return route.fallback();
}
if (pathname === "/api/services") {
return fulfillJson(route, { data: SERVICE_CATALOG });
}
return fulfillJson(route, { data: [] });
});
await page.route("**/api/**/DropdownOptions/ByCategory/**", (route) => {
@ -136,6 +152,7 @@ test.describe("Work Orders advanced filters", () => {
await sheet.getByRole("button", { name: /Indicators/i }).click();
await sheet.getByRole("button", { name: /^Add-On$/i }).click();
await sheet.getByRole("button", { name: /^Service/i }).click();
await expect(sheet.getByText("HVAC", { exact: true })).toBeVisible();
await sheet.getByText("HVAC").click();
await sheet.getByRole("button", { name: /Completion doc/i }).click();
await sheet.getByRole("button", { name: /^Pending$/i }).click();

View file

@ -37,6 +37,29 @@ const VENDORS = [
},
];
const SERVICES = [
{
id: 1,
name: "HVAC",
trade: "HVAC",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
{
id: 2,
name: "Plumbing",
trade: "Plumbing",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
];
const scheduledRow = {
id: 1,
woNumber: "WO-501",
@ -150,6 +173,11 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error"
return;
}
if (pathname === "/api/services") {
await fulfillJson(route, { data: SERVICES });
return;
}
if (pathname.includes("/DropdownOptions/ByCategory/Problem")) {
await fulfillJson(route, {
data: [
@ -192,7 +220,18 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error"
async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" = "default") {
await mockWorkOrderApi(page, mode);
const registryResponses = Promise.all([
page.waitForResponse((response) => {
const url = new URL(response.url());
return url.pathname === "/api/services" && url.searchParams.get("isActive") === "true";
}),
page.waitForResponse((response) => {
const url = new URL(response.url());
return url.pathname === "/api/services" && !url.searchParams.has("isActive");
}),
]);
await page.goto("/workorders");
await registryResponses;
await page.evaluate(() => document.fonts.ready);
await page.addStyleTag({
content: `

View file

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

View file

@ -16,6 +16,10 @@ import type {
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { DocBadge } from "./slide-over-doc-badge";
@ -30,12 +34,102 @@ type SlideOverInfoTabEditViewProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onRequestComplete?: () => void;
};
type SlideOverServiceFieldProps = Pick<
SlideOverInfoTabEditViewProps,
"draft" | "pmTypes" | "services" | "serviceQuery" | "onDraftChange"
>;
function SlideOverServiceField({
draft,
pmTypes,
services = [],
serviceQuery,
onDraftChange,
}: SlideOverServiceFieldProps) {
return (
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
services={services}
serviceQuery={serviceQuery}
serviceId={draft.serviceId}
workOrderType={draft.type}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm, id) => onDraftChange(id != null ? { pm, serviceId: id } : { pm })}
onSaveDetails={(details) =>
onDraftChange({
...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId),
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
avetaRequired: details.avetaRequired,
})
}
/>
</SlideOverInfoRow>
);
}
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;
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))
? { type, severity, serviceId: null, pm: "" }
: { type, severity },
);
}}
/>
);
}
function SlideOverScheduleOnField({
draft,
onDraftChange,
@ -114,6 +208,11 @@ export function SlideOverInfoTabEditView({
sites,
vendors,
pmTypes,
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
onDraftChange,
onDuplicateFound,
@ -162,10 +261,13 @@ export function SlideOverInfoTabEditView({
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
<TypeCell
value={draft.type}
severity={draft.severity}
onSave={(type, severity) => onDraftChange({ type, severity })}
<SlideOverTypeField
draft={draft}
services={services}
serviceHistory={serviceHistory}
serviceHistoryReady={serviceHistoryReady}
serviceHistoryQuery={serviceHistoryQuery}
onDraftChange={onDraftChange}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
@ -192,25 +294,13 @@ export function SlideOverInfoTabEditView({
onSave={(apptTime) => onDraftChange({ apptTime })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm) => onDraftChange({ pm })}
onSaveDetails={(details) =>
onDraftChange({
pm: details.pm,
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
avetaRequired: details.avetaRequired,
})
}
/>
</SlideOverInfoRow>
<SlideOverServiceField
draft={draft}
pmTypes={pmTypes}
services={services}
serviceQuery={serviceQuery}
onDraftChange={onDraftChange}
/>
<SlideOverInfoRow label="Vendor">
<VendorCell
vendorId={draft.vendorId}

View file

@ -4,6 +4,8 @@ import type {
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
@ -17,6 +19,11 @@ type SlideOverInfoTabProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -33,6 +40,11 @@ export function SlideOverInfoTab({
sites,
vendors,
pmTypes,
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
onDraftChange,
onDuplicateFound,
@ -50,6 +62,11 @@ export function SlideOverInfoTab({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
onDraftChange={onDraftChange}
onDuplicateFound={onDuplicateFound}
@ -67,6 +84,7 @@ export function SlideOverInfoTab({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
/>
);
}

View file

@ -12,6 +12,8 @@ import type {
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { resolveSlideOverAvetaRequired } from "@/domain/work-orders/utils/resolve-slide-over-aveta-required";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
@ -25,6 +27,11 @@ type WorkOrderSlideOverTabsProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
commentHighlight?: CommentHighlight | null;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
@ -32,44 +39,24 @@ type WorkOrderSlideOverTabsProps = {
onRequestComplete?: () => void;
};
export function WorkOrderSlideOverTabs({
type SecondarySlideOverTabProps = Pick<
WorkOrderSlideOverTabsProps,
"tab" | "state" | "users" | "commentHighlight" | "onOpenCompletionDoc"
>;
function SecondarySlideOverTabs({
tab,
state,
saving,
users,
sites,
vendors,
pmTypes,
allOrders,
commentHighlight,
onDuplicateFound,
onOpenCompletionDoc,
onRequestComplete,
}: WorkOrderSlideOverTabsProps) {
}: SecondarySlideOverTabProps) {
const hasError = Boolean(state.error) && !state.detailForRow;
const showContent = Boolean(state.detailForRow || !state.isLoading);
const { infoSource, activeDraft } = state;
return (
<Box className="flex-1 overflow-y-auto p-5">
{tab === "info" && infoSource != null && activeDraft != null && (
<SlideOverInfoTab
row={infoSource}
draft={activeDraft}
editing={state.editing && !saving}
isCancelled={state.isCancelled}
isCompleted={state.isCompleted}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}
onRequestComplete={onRequestComplete}
/>
)}
<>
{tab === "comments" && (
<WorkOrderSlideOverTabPanel
variant="comments"
@ -111,7 +98,7 @@ export function WorkOrderSlideOverTabs({
</WorkOrderSlideOverTabPanel>
)}
{tab === "completion" && state.infoSource != null && (
{tab === "completion" && infoSource != null && (
<WorkOrderSlideOverTabPanel
variant="completion"
showSkeleton={state.showDetailSkeleton}
@ -121,16 +108,14 @@ export function WorkOrderSlideOverTabs({
showContent={showContent}
>
<SlideOverCompletionTab
row={state.infoSource}
row={infoSource}
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaCounts={state.mediaCounts}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
state.uploadMediaRequests(requests);
}}
onUploadMedia={(requests) => state.uploadMediaRequests(requests)}
onCategorizeMedia={state.categorizeMedia}
onRemoveMedia={state.removeMedia}
onRetryMedia={state.retryUpload}
@ -161,14 +146,69 @@ export function WorkOrderSlideOverTabs({
)}
isReadOnly={state.isExtrasReadOnly}
pending={state.mediaPending}
onUpload={(files) => {
void state.uploadExtraFiles(files);
}}
onUpload={(files) => void state.uploadExtraFiles(files)}
onRemove={state.removeMedia}
onCategorize={state.categorizeMedia}
/>
</WorkOrderSlideOverTabPanel>
)}
</>
);
}
export function WorkOrderSlideOverTabs({
tab,
state,
saving,
users,
sites,
vendors,
pmTypes,
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
commentHighlight,
onDuplicateFound,
onOpenCompletionDoc,
onRequestComplete,
}: WorkOrderSlideOverTabsProps) {
const { infoSource, activeDraft } = state;
return (
<Box className="flex-1 overflow-y-auto p-5">
{tab === "info" && infoSource != null && activeDraft != null && (
<SlideOverInfoTab
row={infoSource}
draft={activeDraft}
editing={state.editing && !saving}
isCancelled={state.isCancelled}
isCompleted={state.isCompleted}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}
onRequestComplete={onRequestComplete}
/>
)}
<SecondarySlideOverTabs
tab={tab}
state={state}
users={users}
commentHighlight={commentHighlight}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
</Box>
);
}

View file

@ -12,6 +12,8 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { useWorkOrderSlideOver, type SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
@ -28,6 +30,11 @@ type WorkOrderSlideOverProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
@ -50,6 +57,11 @@ export function WorkOrderSlideOver({
sites,
vendors,
pmTypes,
services = [],
serviceHistory,
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
allOrders,
onSave,
onOpenCompletionDoc,
@ -101,6 +113,11 @@ export function WorkOrderSlideOver({
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
commentHighlight={commentHighlight}
onDuplicateFound={onDuplicateFound}

View file

@ -28,8 +28,211 @@ import {
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
type FilterActions = Pick<AdvancedSearchFilterSectionsProps, "draft" | "toggleIn" | "setMany">;
function AdvSelectAllRow({
setMany,
keyName,
values,
selectedCount,
}: {
setMany: AdvancedSearchFilterSectionsProps["setMany"];
keyName: AdvancedFilterSetKey;
values: string[];
selectedCount: number;
}) {
return (
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={selectedCount > 0}
onClick={() => setMany(keyName, values, selectedCount === 0)}
/>
</div>
);
}
function AdvancedStatusSection({
draft,
setDraft,
toggleIn,
setMany,
}: FilterActions & Pick<AdvancedSearchFilterSectionsProps, "setDraft">) {
return (
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
<AdvSelectAllRow
setMany={setMany}
keyName="statuses"
values={[...ALL_WIZARD_STATUSES]}
selectedCount={draft.statuses.size}
/>
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
<AdvStatusPill
key={status}
label={status}
dotColor={WIZARD_STATUS_DOTS[status]}
selected={draft.statuses.has(status)}
selectedBg="var(--color-primary-tint)"
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>
);
}
type AdvancedServiceSectionProps = FilterActions &
Pick<AdvancedSearchFilterSectionsProps, "services" | "serviceQuery" | "pmTypes">;
function buildServiceFilterOptions(
draft: WorkOrderAdvancedFilters,
services: Service[] | undefined,
pmTypes: string[] | undefined,
) {
const serviceRegistryManaged = services !== undefined;
const serviceList = services ?? [];
const options = serviceRegistryManaged
? [
...serviceList.map((service) => ({ value: service.name, label: service.name })),
...[...draft.pmTypes]
.filter((name) => !serviceList.some((service) => service.name === name))
.map((name) => ({ value: name, label: name })),
]
: (pmTypes ?? []).map((name) => ({ value: name, label: name }));
return { serviceRegistryManaged, serviceList, options };
}
function AdvancedServiceRegistryFeedback({
query,
managed,
services,
}: {
query?: ServiceRegistryQueryState;
managed: boolean;
services: Service[];
}) {
const showEmpty =
managed &&
query?.hasData === true &&
!query.isLoading &&
!query.isError &&
services.length === 0;
return (
<>
{Boolean(query?.isLoading && !query.hasData) && (
<div role="status" aria-label="Loading services">
<Text as="span" variant="description">
Loading services…
</Text>
</div>
)}
{Boolean(query?.isFetching && query.hasData && !query.isError) && (
<div role="status" aria-label="Updating services">
<Text as="span" variant="description">
Updating services…
</Text>
</div>
)}
{Boolean(query?.isError) && (
<div role="alert" aria-label="Service filters unavailable">
<QueryErrorPanel
compact
message="Service filters couldn't load. Retry to load registry services."
onRetry={query?.retry}
/>
</div>
)}
{showEmpty && (
<Text as="div" variant="description">
No services are available.
</Text>
)}
</>
);
}
function AdvancedServiceSection({
draft,
toggleIn,
setMany,
services,
serviceQuery,
pmTypes,
}: AdvancedServiceSectionProps) {
const { serviceRegistryManaged, serviceList, options } = buildServiceFilterOptions(
draft,
services,
pmTypes,
);
const unavailable =
serviceRegistryManaged &&
(serviceQuery?.isLoading === true ||
(serviceQuery?.isError === true && serviceQuery.hasData === false));
return (
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvancedServiceRegistryFeedback
query={serviceQuery}
managed={serviceRegistryManaged}
services={serviceList}
/>
<AdvMultiSelect
options={options}
selected={draft.pmTypes}
onToggle={(value) => toggleIn("pmTypes", value)}
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
placeholder="Search service…"
disabled={unavailable}
renderLeft={(name) => (
<PmTypeIcon pmType={name} size={14} className="shrink-0 text-muted-foreground" />
)}
/>
</AdvSection>
);
}
function AdvancedCompletionDocSection({ draft, toggleIn, setMany }: FilterActions) {
return (
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
<AdvSelectAllRow
setMany={setMany}
keyName="docs"
values={[...COMPLETION_DOC_OPTIONS]}
selectedCount={draft.docs.size}
/>
<div className="flex items-center gap-2">
{COMPLETION_DOC_OPTIONS.map((doc) => (
<AdvDocPill
key={doc}
label={DOC_FILTER_LABELS[doc]}
selected={draft.docs.has(doc)}
onClick={() => toggleIn("docs", doc)}
/>
))}
</div>
</AdvSection>
);
}
type AdvancedSearchFilterSectionsProps = {
draft: WorkOrderAdvancedFilters;
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
@ -37,7 +240,9 @@ type AdvancedSearchFilterSectionsProps = {
setMany: (key: AdvancedFilterSetKey, values: string[], select: boolean) => void;
sites: { id: string | number; name: string }[];
users: WorkOrderUser[];
pmTypes: string[];
services?: Service[];
serviceQuery?: ServiceRegistryQueryState;
pmTypes?: string[];
vendorTechs: string[];
};
@ -48,18 +253,11 @@ export function AdvancedSearchFilterSections({
setMany,
sites,
users,
pmTypes,
services,
serviceQuery,
pmTypes = [],
vendorTechs,
}: AdvancedSearchFilterSectionsProps) {
const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => (
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={selectedSize > 0}
onClick={() => setMany(key, all, selectedSize === 0)}
/>
</div>
);
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4">
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
@ -109,48 +307,23 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
<AdvStatusPill
key={status}
label={status}
dotColor={WIZARD_STATUS_DOTS[status]}
selected={draft.statuses.has(status)}
selectedBg="var(--color-primary-tint)"
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>
<AdvancedStatusSection
draft={draft}
setDraft={setDraft}
toggleIn={toggleIn}
setMany={setMany}
/>
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvMultiSelect
options={pmTypes.map((pmType) => ({ value: pmType, label: pmType }))}
selected={draft.pmTypes}
onToggle={(value) => toggleIn("pmTypes", value)}
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
placeholder="Search service…"
renderLeft={(pmType) => (
<PmTypeIcon pmType={pmType} size={14} className="shrink-0 text-muted-foreground" />
)}
/>
</AdvSection>
<AdvancedServiceSection
draft={draft}
toggleIn={toggleIn}
setMany={setMany}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
/>
<AdvSection icon={Truck} label="Vendor / tech" count={draft.vendorTechs.size}>
<AdvMultiSelect
@ -162,19 +335,7 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
{selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)}
<div className="flex items-center gap-2">
{COMPLETION_DOC_OPTIONS.map((doc) => (
<AdvDocPill
key={doc}
label={DOC_FILTER_LABELS[doc]}
selected={draft.docs.has(doc)}
onClick={() => toggleIn("docs", doc)}
/>
))}
</div>
</AdvSection>
<AdvancedCompletionDocSection draft={draft} toggleIn={toggleIn} setMany={setMany} />
<AdvFlagInternalUpliftSections
draft={draft}

View file

@ -24,6 +24,8 @@ type AdvMultiSelectProps = {
placeholder: string;
renderLeft?: (value: string) => ReactNode;
disabled?: boolean;
};
export function AdvMultiSelect({
@ -38,6 +40,7 @@ export function AdvMultiSelect({
placeholder,
renderLeft,
disabled = false,
}: AdvMultiSelectProps) {
const [query, setQuery] = useState("");
@ -55,6 +58,7 @@ export function AdvMultiSelect({
placeholder={placeholder}
fullWidth
className="mb-2"
disabled={disabled}
/>
<div className="mb-1.5 flex items-center justify-between">
@ -64,6 +68,7 @@ export function AdvMultiSelect({
<SelectAllAction
anySelected={anySelected}
disabled={disabled}
onClick={() =>
anySelected
? onBulkToggle?.(
@ -87,6 +92,7 @@ export function AdvMultiSelect({
selected={selected.has(option.value)}
label={option.label}
left={renderLeft?.(option.value)}
disabled={disabled}
onClick={() => onToggle(option.value)}
/>
))}

View file

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

View file

@ -4,22 +4,41 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
function resolveInlineDraftLocation(draft: WorkOrderTableDraft, sites: LocationOption[]) {
const location = sites.find((site) => String(site.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
return {
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
};
}
function resolveInlineDraftSchedule(draft: WorkOrderTableDraft) {
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
return {
scheduleWeekOnly,
status: resolveCreationStatus({
status: draft.status || "Incomplete",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly,
}),
};
}
export function buildInlineDraftCreateInput(
draft: WorkOrderTableDraft,
users: WorkOrderUser[],
sites: LocationOption[],
): WorkOrderWizardDraft {
const assignee = users.find((u) => String(u.id) === draft.dispatcherId);
const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
const location = resolveInlineDraftLocation(draft, sites);
const schedule = resolveInlineDraftSchedule(draft);
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
locationName: location.locationName,
siteCode: location.siteCode,
woNumber: draft.woNumber,
type: draft.type || "PM",
severity: draft.severity,
@ -28,7 +47,7 @@ export function buildInlineDraftCreateInput(
scheduledOn: draft.scheduledOn,
dueDate: draft.dueDate,
pm: draft.pm,
serviceId: null,
serviceId: draft.serviceId ?? null,
extraServices: draft.extraServices ?? [],
avetaRequired: Boolean(draft.avetaRequired),
pmNote: draft.serviceNotes ?? "",
@ -39,16 +58,12 @@ export function buildInlineDraftCreateInput(
vendorNotes: "",
apptStart: draft.apptStart,
apptEnd: draft.apptEnd,
status: resolveCreationStatus({
status: draft.status || "Incomplete",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly,
}),
status: schedule.status,
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
additionalContacts: draft.additionalContacts ?? [],
scheduleWeekOnly,
scheduleWeekOnly: schedule.scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
scheduleEnd: draft.scheduleEnd || "",
schedMode: draft.schedMode,

View file

@ -13,12 +13,16 @@ import {
hasServiceNotesDetails,
truncateServiceNotesPreview,
} from "@/domain/work-orders/utils/service-notes-ui";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { servicesForWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type PMTypeCellProps = {
value: string;
pmTypes: string[];
q?: string;
onSave: (v: string) => void;
onSave: (v: string, serviceId?: Service["id"]) => void;
serviceNotes?: string;
extraServices?: string[];
avetaRequired?: boolean;
@ -26,155 +30,284 @@ type PMTypeCellProps = {
woNumber?: string;
onSaveDetails?: (details: ServiceNotesDetails) => void;
viewOnly?: boolean;
services?: Service[];
serviceId?: string | number | null;
workOrderType?: WOType | "";
serviceQuery?: ServiceRegistryQueryState;
};
export function PMTypeCell({
value,
pmTypes,
q,
onSave,
serviceNotes = "",
extraServices = [],
avetaRequired = false,
avetaAttached = false,
woNumber,
onSaveDetails,
viewOnly = false,
}: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const [modalOpen, setModalOpen] = useState(false);
type PMTypeCellModel = {
assignableServices?: Service[];
displayValue: string;
filtered: string[];
registryManaged: boolean;
choicesUnavailable: boolean;
catalogEmpty: boolean;
hasDetails: boolean;
notePreview: string;
};
const filtered = search
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
: pmTypes;
function getAssignableServices(props: PMTypeCellProps) {
return props.services == null
? undefined
: servicesForWorkOrderType(props.services, props.workOrderType ?? "");
}
const extras = extraServices ?? [];
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
function getCellDisplayValue(props: PMTypeCellProps) {
const linkedService = props.services?.find(
(service) => String(service.id) === String(props.serviceId),
);
return !props.viewOnly && linkedService?.isActive ? linkedService.name : props.value;
}
const closePopover = () => {
setAnchorEl(null);
setSearch("");
function getServiceChoicesUnavailable(props: PMTypeCellProps, registryManaged: boolean) {
const query = props.serviceQuery;
return (
registryManaged &&
(query?.isLoading === true || (query?.isError === true && query.hasData === false))
);
}
function isServiceCatalogEmpty(
props: PMTypeCellProps,
registryManaged: boolean,
services: Service[] | undefined,
) {
const query = props.serviceQuery;
return (
registryManaged &&
(query?.hasData ?? true) &&
!query?.isLoading &&
!query?.isError &&
(services?.length ?? props.pmTypes.length) === 0
);
}
function resolvePMTypeCellModel(props: PMTypeCellProps, search: string): PMTypeCellModel {
const assignableServices = getAssignableServices(props);
const serviceNames = assignableServices?.map((service) => service.name) ?? props.pmTypes;
const registryManaged = props.services !== undefined;
return {
assignableServices,
displayValue: getCellDisplayValue(props),
filtered: search
? serviceNames.filter((name) => name.toLowerCase().includes(search.toLowerCase()))
: serviceNames,
registryManaged,
choicesUnavailable: getServiceChoicesUnavailable(props, registryManaged),
catalogEmpty: isServiceCatalogEmpty(props, registryManaged, assignableServices),
hasDetails: hasServiceNotesDetails(
props.serviceNotes ?? "",
props.extraServices ?? [],
props.avetaRequired ?? false,
),
notePreview: props.serviceNotes?.trim() ? truncateServiceNotesPreview(props.serviceNotes) : "",
};
}
const modal =
onSaveDetails != null || viewOnly ? (
<ServiceNotesDialog
open={modalOpen}
onOpenChange={setModalOpen}
pm={value}
extraServices={extras}
serviceNotes={serviceNotes ?? ""}
avetaRequired={avetaRequired}
woNumber={woNumber}
pmTypes={pmTypes}
viewOnly={viewOnly}
onSave={onSaveDetails ?? (() => undefined)}
function PMTypeCellDialog({
props,
model,
open,
onOpenChange,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
if (!props.viewOnly && props.onSaveDetails == null) return null;
return (
<ServiceNotesDialog
open={open}
onOpenChange={onOpenChange}
pm={model.displayValue}
extraServices={props.extraServices ?? []}
serviceNotes={props.serviceNotes ?? ""}
avetaRequired={props.avetaRequired ?? false}
woNumber={props.woNumber}
pmTypes={props.pmTypes}
services={model.assignableServices}
serviceId={props.serviceId}
serviceQuery={props.serviceQuery}
viewOnly={props.viewOnly ?? false}
onSave={props.onSaveDetails ?? (() => undefined)}
/>
);
}
function PMTypeCellReadOnly({
props,
model,
onOpenDetails,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
onOpenDetails: () => void;
}) {
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenDetails();
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={model.displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
) : null;
if (viewOnly) {
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
{model.displayValue ? (
<span
className="min-w-0 truncate"
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<PmTypeIcon
pmType={value}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
<span
className="min-w-0 truncate"
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<HighlightText text={value} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
</button>
{modal}
</>
);
}
<HighlightText text={model.displayValue} q={props.q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
{Boolean(props.avetaRequired) && <AvetaBadge size={11} attached={props.avetaAttached} />}
</button>
);
}
if (hasDetails && onSaveDetails) {
return (
<>
<PmTypeDetailsTrigger
value={value}
q={q}
notePreview={notePreview}
extrasCount={extras.length}
avetaRequired={avetaRequired}
avetaAttached={avetaAttached}
onOpenDetails={() => setModalOpen(true)}
/>
{modal}
</>
);
}
function PMTypeCellDetails({
props,
model,
onOpenDetails,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
onOpenDetails: () => void;
}) {
return (
<PmTypeDetailsTrigger
value={model.displayValue}
q={props.q}
notePreview={model.notePreview}
extrasCount={(props.extraServices ?? []).length}
avetaRequired={props.avetaRequired ?? false}
avetaAttached={props.avetaAttached ?? false}
onOpenDetails={onOpenDetails}
/>
);
}
function PMTypeCellPicker({
props,
model,
anchorEl,
search,
onOpenPicker,
onSearchChange,
onClose,
onOpenDetails,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
anchorEl: HTMLElement | null;
search: string;
onOpenPicker: (anchor: HTMLElement) => void;
onSearchChange: (search: string) => void;
onClose: () => void;
onOpenDetails: () => void;
}) {
const onPick = (name: string) => {
const service = model.assignableServices?.find((candidate) => candidate.name === name);
props.onSave(name, service?.id);
onClose();
};
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
onClick={(event) => {
event.stopPropagation();
onOpenPicker(event.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={value}
pmType={model.displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
{model.displayValue ? (
<span
className="min-w-0 truncate"
style={{
fontSize: 13,
color: "var(--color-text-primary)",
}}
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<HighlightText text={value} q={q} />
<HighlightText text={model.displayValue} q={props.q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
)}
<HoverChevron />
</button>
<PmTypePickerPopover
anchorEl={anchorEl}
search={search}
filtered={filtered}
value={value}
showAddDetails={Boolean(onSaveDetails)}
onSearchChange={setSearch}
onClose={closePopover}
onPick={(pm) => {
onSave(pm);
closePopover();
}}
filtered={model.filtered}
value={model.displayValue}
showAddDetails={Boolean(props.onSaveDetails)}
onSearchChange={onSearchChange}
onClose={onClose}
registryManaged={model.registryManaged}
loading={props.serviceQuery?.isLoading === true && !props.serviceQuery.hasData}
fetching={props.serviceQuery?.isFetching === true && props.serviceQuery.hasData}
error={props.serviceQuery?.isError === true}
empty={model.catalogEmpty}
selectionDisabled={model.choicesUnavailable}
onRetry={props.serviceQuery?.retry}
onPick={onPick}
onAddDetails={() => {
closePopover();
setModalOpen(true);
onClose();
onOpenDetails();
}}
/>
{modal}
</>
);
}
export function PMTypeCell(props: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const model = resolvePMTypeCellModel(props, search);
const closePicker = () => {
setAnchorEl(null);
setSearch("");
};
const openDetails = () => setModalOpen(true);
const content = props.viewOnly ? (
<PMTypeCellReadOnly props={props} model={model} onOpenDetails={openDetails} />
) : model.hasDetails && props.onSaveDetails ? (
<PMTypeCellDetails props={props} model={model} onOpenDetails={openDetails} />
) : (
<PMTypeCellPicker
props={props}
model={model}
anchorEl={anchorEl}
search={search}
onOpenPicker={setAnchorEl}
onSearchChange={setSearch}
onClose={closePicker}
onOpenDetails={openDetails}
/>
);
return (
<>
{content}
<PMTypeCellDialog props={props} model={model} open={modalOpen} onOpenChange={setModalOpen} />
</>
);
}

View file

@ -1,5 +1,6 @@
import { Check, Plus } from "lucide-react";
import { Popover } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
type PmTypePickerPopoverProps = {
@ -12,6 +13,13 @@ type PmTypePickerPopoverProps = {
onClose: () => void;
onPick: (pm: string) => void;
onAddDetails: () => void;
registryManaged?: boolean;
loading?: boolean;
fetching?: boolean;
error?: boolean;
empty?: boolean;
selectionDisabled?: boolean;
onRetry?: () => void;
};
export function PmTypePickerPopover({
@ -24,6 +32,13 @@ export function PmTypePickerPopover({
onClose,
onPick,
onAddDetails,
registryManaged = false,
loading = false,
fetching = false,
error = false,
empty = false,
selectionDisabled = false,
onRetry,
}: PmTypePickerPopoverProps) {
return (
<Popover
@ -40,11 +55,44 @@ export function PmTypePickerPopover({
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search service…"
disabled={selectionDisabled}
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
/>
</div>
<div className="max-h-52 overflow-y-auto">
{loading && (
<div
role="status"
aria-label="Loading services"
className="px-2 py-3 text-xs text-muted-foreground"
>
Loading services…
</div>
)}
{Boolean(fetching && !error) && (
<div
role="status"
aria-label="Updating services"
className="px-2 py-2 text-xs text-muted-foreground"
>
Updating services…
</div>
)}
{error && (
<div role="alert" aria-label="Service options unavailable">
<QueryErrorPanel
compact
message="Service options couldn't load. Retry to load registry services."
onRetry={onRetry}
/>
</div>
)}
{Boolean(registryManaged && empty) && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No services are available for this work-order type.
</div>
)}
{showAddDetails && (
<button
type="button"
@ -66,6 +114,7 @@ export function PmTypePickerPopover({
key={pm}
type="button"
onClick={() => onPick(pm)}
disabled={selectionDisabled}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
@ -78,6 +127,9 @@ export function PmTypePickerPopover({
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
</button>
))}
{Boolean(!loading && !error && !empty && filtered.length === 0) && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">No matches</div>
)}
</div>
</Popover>
);

View file

@ -1,17 +1,90 @@
import { useEffect, 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";
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;
extraServices: string[];
serviceNotes: string;
avetaRequired: boolean;
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;
@ -21,10 +94,261 @@ type ServiceNotesDialogProps = {
avetaRequired?: boolean;
woNumber?: string;
pmTypes: string[];
services?: Service[];
serviceId?: string | number | null;
serviceQuery?: ServiceRegistryQueryState;
viewOnly?: boolean;
onSave: (details: ServiceNotesDetails) => void;
};
type DialogTarget = {
key: string;
pm: string;
serviceId?: string | number | null;
};
function getDialogTarget(
woNumber: string | undefined,
serviceId: string | number | null | undefined,
pm: string,
) {
const key = woNumber ? `wo:${woNumber}` : serviceId != null ? `service:${serviceId}` : `pm:${pm}`;
return { key, pm, serviceId };
}
function selectedServices(pm: string, extraServices: string[]) {
return [pm, ...extraServices].filter(
(value, index, values) => !!value && values.indexOf(value) === index,
);
}
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 resolveRenamedPrimaryName({
selectedName,
selectedServiceId,
serviceIdsByName,
services,
previous,
pm,
serviceId,
}: {
selectedName: string | undefined;
selectedServiceId?: string | number | null;
serviceIdsByName: Record<string, string | number>;
services?: Service[];
previous: DialogTarget;
pm: string;
serviceId?: string | number | null;
}) {
const chosenService = services?.find(
(service) => String(service.id) === String(selectedServiceId),
);
const selectionTracksChosenId =
selectedName != null &&
selectedServiceId != null &&
String(serviceIdsByName[selectedName]) === String(selectedServiceId);
if (selectionTracksChosenId && chosenService != null) return chosenService.name;
const savedServiceUnchanged =
previous.serviceId != null && String(previous.serviceId) === String(serviceId);
const selectedSavedId = String(selectedServiceId ?? "") === String(serviceId ?? "");
const selectedSavedLabel = selectedName === previous.pm;
return savedServiceUnchanged && selectedSavedId && selectedSavedLabel && previous.pm !== pm
? pm
: undefined;
}
function useServiceNotesDraft({
open,
target,
pm,
serviceId,
extraServices,
serviceNotes,
avetaRequired,
services,
}: {
open: boolean;
target: DialogTarget;
pm: string;
serviceId: string | number | null | undefined;
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;
useEffect(() => {
if (!open) {
lastTarget.current = null;
return;
}
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("");
} else {
const renamedPrimary = resolveRenamedPrimaryName({
selectedName: sel[0],
selectedServiceId: primaryServiceId,
serviceIdsByName,
services,
previous,
pm,
serviceId,
});
if (renamedPrimary != null && renamedPrimary !== sel[0]) {
setSel((current) =>
current[0] === sel[0] ? [renamedPrimary, ...current.slice(1)] : current,
);
setServiceIdsByName((current) => ({
...current,
[renamedPrimary]: primaryServiceId ?? serviceId!,
}));
}
}
lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId };
}, [
open,
targetKey,
targetPm,
targetServiceId,
pm,
serviceId,
sel,
primaryServiceId,
serviceIdsByName,
extraServices,
services,
serviceNotes,
avetaRequired,
]);
return {
sel,
setSel,
note,
setNote,
aveta,
setAveta,
search,
setSearch,
primaryServiceId,
setPrimaryServiceId,
serviceIdsByName,
setServiceIdsByName,
};
}
export function ServiceNotesDialog({
open,
onOpenChange,
@ -34,30 +358,55 @@ export function ServiceNotesDialog({
avetaRequired = false,
woNumber,
pmTypes,
services,
serviceId,
serviceQuery,
viewOnly = false,
onSave,
}: ServiceNotesDialogProps) {
const [sel, setSel] = useState<string[]>([]);
const [note, setNote] = useState("");
const [aveta, setAveta] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) {
return;
}
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
setNote(serviceNotes ?? "");
setAveta(Boolean(avetaRequired));
setSearch("");
}, [open, pm, extraServices, serviceNotes, avetaRequired]);
const toggle = (value: string) => {
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
};
const services = pmTypes.map((value) => ({ id: value, value }));
const target = getDialogTarget(woNumber, serviceId, pm);
const {
sel,
setSel,
note,
setNote,
aveta,
setAveta,
search,
setSearch,
primaryServiceId,
setPrimaryServiceId,
serviceIdsByName,
setServiceIdsByName,
} = useServiceNotesDraft({
open,
target,
pm,
serviceId,
extraServices,
serviceNotes,
avetaRequired,
services,
});
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}
@ -83,19 +432,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">
<WizardServiceNotesFields
services={services}
sel={sel}
onToggle={toggle}
note={note}
setNote={setNote}
search={search}
setSearch={setSearch}
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
@ -121,6 +475,7 @@ export function ServiceNotesDialog({
onClick={() => {
onSave({
pm: sel[0] ?? "",
serviceId: saveServiceId(),
extraServices: sel.slice(1),
serviceNotes: note,
avetaRequired: aveta,

View file

@ -0,0 +1,56 @@
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;
}

View file

@ -13,12 +13,17 @@ 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;
registryMissing?: boolean;
serviceHistoryQuery?: ServiceRegistryQueryState;
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
};
@ -29,7 +34,16 @@ 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,
registryMissing = false,
serviceHistoryQuery,
onSave,
}: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
@ -65,6 +79,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 +92,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 +128,14 @@ export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }
))}
</Popover>
{registryUnavailable && <ServiceRegistryStatus query={serviceHistoryQuery} unavailable />}
{registryMissing && (
<div className="text-xs text-muted-foreground" role="status">
This linked service is no longer in the registry. Changing type clears its assignment; add
a replacement in Service &amp; Notes if needed.
</div>
)}
<TypeSeverityModal
open={modalOpen}
onOpenChange={setModalOpen}

View file

@ -5,6 +5,8 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { Service } from "@/domain/services/types/service";
import { serviceSupportsWorkOrderType } from "@/domain/services/utils/services-for-work-order-type";
import {
formatSeverityEscalationToast,
shouldAnnounceSeverityEscalation,
@ -16,18 +18,37 @@ type PersistPatchFn = (
options?: { onSuccess?: () => void },
) => void;
export function saveExistingRowType(
onPatch: PersistPatchFn,
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
type: WOType,
severity: WorkOrderSeverity | null,
) {
const patch = { type, severity };
type SaveExistingRowTypeOptions = {
onPatch: PersistPatchFn;
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity" | "serviceId">;
type: WOType;
severity: WorkOrderSeverity | null;
serviceHistory?: Service[];
serviceHistoryReady?: boolean;
};
export function saveExistingRowType({
onPatch,
row,
type,
severity,
serviceHistory = [],
serviceHistoryReady = true,
}: SaveExistingRowTypeOptions): boolean {
const selectedService = serviceHistoryReady
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
: undefined;
const clearService =
row.serviceId != null &&
(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);
return;
return true;
}
onPatch(row.id, patch, {
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
});
return true;
}

View file

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

View file

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

View file

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

View file

@ -17,12 +17,18 @@ import { SiteCell } from "./cells/site-cell";
import { TypeCell } from "./cells/type-cell";
import type { WoTableRowHandlers } from "./wo-table-row";
import { resolveDueDateFloor } from "@/domain/work-orders/utils/due-date-floor";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
type WoTableRowCellsProps = {
row: WorkOrderTableRow;
allRows: WorkOrderTableRow[];
users: WorkOrderUser[];
sites: LocationOption[];
services?: Service[];
serviceHistory?: Service[];
serviceHistoryReady?: boolean;
serviceHistoryQuery?: ServiceRegistryQueryState;
sitePlace?: { city: string; state: string };
search?: string;
forceScheduleHighlight?: boolean;
@ -34,11 +40,73 @@ 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;
const linkedServiceMissing =
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}
registryMissing={linkedServiceMissing}
serviceHistoryQuery={serviceHistoryQuery}
onSave={(type, severity) =>
saveExistingRowType({
onPatch: handlers.onPatch,
row,
type,
severity,
serviceHistory: typeServices,
serviceHistoryReady,
})
}
/>
</td>
);
}
export function WoTableRowCells({
row,
allRows,
users,
sites,
services = [],
serviceHistory,
serviceHistoryReady = true,
serviceHistoryQuery,
sitePlace,
search,
forceScheduleHighlight,
@ -109,19 +177,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(handlers.onPatch, row, t, sev)}
/>
</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 }}

View file

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

View file

@ -23,6 +23,8 @@ import {
resolveFirstCellShadow,
resolveWoTableRowStyle,
} from "./wo-table-row-style";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
export type WoTableRowHandlers = {
onRowClick: (row: WorkOrderTableRow) => void;
@ -59,6 +61,11 @@ type WoTableRowProps = {
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
search?: string;
forceScheduleHighlight?: boolean;
forceDueDateHighlight?: boolean;
@ -101,6 +108,11 @@ export function WoTableRow({
sitePlace,
vendors,
pmTypes,
services = [],
serviceHistory = [],
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
search,
forceScheduleHighlight,
forceDueDateHighlight,
@ -197,6 +209,10 @@ export function WoTableRow({
allRows={allRows}
users={users}
sites={sites}
services={services}
serviceHistory={serviceHistory}
serviceHistoryReady={serviceHistoryReady}
serviceHistoryQuery={serviceHistoryQuery}
sitePlace={sitePlace}
search={search}
forceScheduleHighlight={forceScheduleHighlight}
@ -210,6 +226,8 @@ export function WoTableRow({
<WoTableRowServiceCells
row={row}
vendors={vendors}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
search={search}
mutationCellStyle={coreLockStyle}

View file

@ -21,6 +21,8 @@ import { WoTableInlineRow } from "./wo-table-inline-row";
import { WoTableRow } from "./wo-table-row";
import { WoTableEmptyState, WoTableShell } from "./wo-table-shell";
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
type WoTableProps = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
@ -28,6 +30,11 @@ type WoTableProps = {
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
services?: Service[];
serviceHistory?: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady?: boolean;
weekMonday: string;
onNavigateToDay: (dateIso: string) => void;
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
@ -87,12 +94,124 @@ function WoTablePager({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTable
);
}
type WoTableRowRenderProps = {
row: WorkOrderTableRow;
allRows: WorkOrderTableRow[];
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
services: Service[];
serviceHistory: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryQuery?: ServiceRegistryQueryState;
serviceHistoryReady: boolean;
pmTypes: string[];
search: string;
forceScheduleHighlight: boolean;
forceDueDateHighlight: boolean;
isSelected: boolean;
isFlashing: boolean;
dragViz: ReturnTypeOfUseWorkOrdersTableData["dragViz"];
handlers: ReturnType<typeof useWoTableRowHandlers>;
};
function WoTableRenderedRow(props: WoTableRowRenderProps) {
return <WoTableRow {...props} />;
}
type WoTableInlineRenderProps = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
dayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
services: Service[];
serviceQuery?: ServiceRegistryQueryState;
pmTypes: string[];
saving: boolean;
handleInlineSave: () => void;
onDuplicateFound: (row: WorkOrderTableRow) => void;
};
function WoTableRenderedInlineRow({
tableData,
dayKey,
users,
sites,
sitePlace,
vendors,
services,
serviceQuery,
pmTypes,
saving,
handleInlineSave,
onDuplicateFound,
}: WoTableInlineRenderProps) {
if (tableData.draftDayKey !== dayKey) return null;
return (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
sitePlace={sitePlace}
vendors={vendors}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={saving}
onChange={tableData.setDraft}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={onDuplicateFound}
/>
);
}
function getWoTableEmptyResult({
tableData,
onOpenAdvancedFilters,
onClearSearch,
}: {
tableData: ReturnTypeOfUseWorkOrdersTableData;
onOpenAdvancedFilters?: () => void;
onClearSearch?: () => void;
}) {
if (tableData.advActive && tableData.scopeTotalCount === 0) {
return (
<WoTableEmptyShell
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
);
}
if (!tableData.advActive && tableData.noSearchResults) {
return (
<WoTableEmptyShell
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
);
}
return null;
}
export function WoTable({
tableData,
users,
sites,
vendors,
pmTypes,
services = [],
serviceHistory = [],
serviceQuery,
serviceHistoryQuery,
serviceHistoryReady = true,
weekMonday,
onNavigateToDay,
onOpenSlideOver,
@ -131,7 +250,7 @@ export function WoTable({
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<WoTableRow
<WoTableRenderedRow
key={String(row.id)}
row={row}
allRows={tableData.rows}
@ -141,6 +260,11 @@ export function WoTable({
effectiveSitePlace(row) ?? resolveSitePlace(row.locationId, row.site, sitePlaceLookups)
}
vendors={vendors}
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryQuery={serviceHistoryQuery}
serviceHistoryReady={serviceHistoryReady}
pmTypes={pmTypes}
search={tableData.search}
forceScheduleHighlight={forceScheduleHighlight}
@ -152,28 +276,26 @@ export function WoTable({
/>
);
const renderInlineRow = (dayKey: string) =>
tableData.draftDayKey === dayKey && (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
sitePlace={resolveSitePlace(
tableData.draft.locationId,
tableData.draft.site,
sitePlaceLookups,
)}
vendors={vendors}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={saving}
onChange={tableData.setDraft}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/>
);
const renderInlineRow = (dayKey: string) => (
<WoTableRenderedInlineRow
tableData={tableData}
dayKey={dayKey}
users={users}
sites={sites}
sitePlace={resolveSitePlace(
tableData.draft.locationId,
tableData.draft.site,
sitePlaceLookups,
)}
vendors={vendors}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
saving={saving}
handleInlineSave={handleInlineSave}
onDuplicateFound={setDuplicateRow}
/>
);
if (tableData.isLoading) {
return (
@ -181,29 +303,12 @@ export function WoTable({
);
}
const emptyAdv = tableData.advActive && tableData.scopeTotalCount === 0;
if (emptyAdv) {
return (
<WoTableEmptyShell
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
);
}
const showSearchEmpty = !tableData.advActive && tableData.noSearchResults;
if (showSearchEmpty) {
return (
<WoTableEmptyShell
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
);
}
const emptyResult = getWoTableEmptyResult({
tableData,
onOpenAdvancedFilters,
onClearSearch,
});
if (emptyResult) return emptyResult;
return (
<>

View file

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

View file

@ -10,13 +10,14 @@ 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;
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)",
@ -80,7 +83,8 @@ 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]"
>
<span

View file

@ -0,0 +1,35 @@
import type { UseQueryResult } from "@tanstack/react-query";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import {
useActiveServices,
useAllServices,
} from "@/domain/services/use-cases/use-services-for-work-order-type";
function toServiceRegistryQueryState(
query: UseQueryResult<Service[], Error>,
): ServiceRegistryQueryState {
return {
isLoading: query.isPending && query.data === undefined,
isError: query.isError,
isFetching: query.isFetching,
hasData: query.data !== undefined,
retry: () => void query.refetch(),
};
}
export function useWorkOrderServiceRegistry() {
const activeQuery = useActiveServices();
const historyQuery = useAllServices();
const servicePickerState = toServiceRegistryQueryState(activeQuery);
const serviceHistoryState = toServiceRegistryQueryState(historyQuery);
const serviceHistoryReadyForTypeChange = serviceHistoryState.hasData;
return {
services: activeQuery.data ?? [],
serviceHistory: historyQuery.data ?? [],
servicePickerState,
serviceHistoryState,
serviceHistoryReadyForTypeChange,
};
}

View file

@ -18,6 +18,7 @@ import {
import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
import { useWorkOrderServiceRegistry } from "./use-work-order-service-registry";
function useSlideOverOpenState() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
@ -91,6 +92,7 @@ export function useWorkOrdersListPage() {
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const serviceRegistry = useWorkOrderServiceRegistry();
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
@ -195,6 +197,7 @@ export function useWorkOrdersListPage() {
locations,
vendors,
pmTypes,
...serviceRegistry,
vendorTechs,
activeSlideOverRow,
activeDocRow,

View file

@ -110,6 +110,11 @@ export default function WorkOrdersListPage() {
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
services={page.services}
serviceHistory={page.serviceHistory}
serviceQuery={page.servicePickerState}
serviceHistoryQuery={page.serviceHistoryState}
serviceHistoryReady={page.serviceHistoryReadyForTypeChange}
weekMonday={filters.weekMonday}
onNavigateToDay={filters.navigateToDay}
onOpenSlideOver={page.handleOpenSlideOver}

View file

@ -0,0 +1,7 @@
export type ServiceRegistryQueryState = {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
hasData: boolean;
retry: () => void;
};

View file

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

View file

@ -0,0 +1,19 @@
import type { Service, ServiceWorkOrderType } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export function serviceWorkOrderType(type: WOType | ""): ServiceWorkOrderType {
return type === "Reactive" || type === "Emergency" ? type : "PM";
}
export function servicesForWorkOrderType(services: Service[], type: WOType | ""): Service[] {
return services.filter((service) => isServiceEligibleForWorkOrderType(service, type));
}
export function isServiceEligibleForWorkOrderType(service: Service, type: WOType | ""): boolean {
return service.isActive && serviceSupportsWorkOrderType(service, type);
}
/** Whether a service definition supports a WO type, regardless of active assignment status. */
export function serviceSupportsWorkOrderType(service: Service, type: WOType | ""): boolean {
return service.supportedWorkOrderTypes.includes(serviceWorkOrderType(type));
}

View file

@ -21,12 +21,18 @@ type ScalarBoardOpRule = {
/** One row per scalar patch field, in emission order. */
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string },
// Severity before type so Emergency/Reactive never land without required severity.
// Severity before type, then service clear before type because the backend validates
// the currently assigned service against the requested work-order type.
{
key: "severity",
field: "severity",
toValue: (value) => (value == null ? "" : String(value)),
},
{
key: "serviceId",
field: "serviceId",
toValue: (value) => (value == null ? "" : String(value)),
},
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
{ key: "site", field: "siteCode", toValue: (value) => value as string },
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },
@ -52,12 +58,27 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
},
];
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
export function expandScalarBoardOps(
patch: WorkOrderTablePatch,
currentRow?: { serviceId?: string | number | null; type?: string },
): BoardFieldOp[] {
const ops: BoardFieldOp[] = [];
const reassignmentAcrossType =
patch.type !== undefined &&
currentRow?.type !== undefined &&
patch.type !== currentRow.type &&
patch.serviceId != null &&
(currentRow.serviceId == null || String(currentRow.serviceId) !== String(patch.serviceId));
if (reassignmentAcrossType && currentRow.serviceId != null) {
ops.push({ field: "serviceId", value: "" });
}
for (const rule of SCALAR_BOARD_OP_RULES) {
const value = patch[rule.key];
if (value === undefined) continue;
if (rule.key === "pm" && patch.serviceId !== undefined) continue;
if (reassignmentAcrossType && rule.key === "serviceId") continue;
ops.push({
field: rule.field,
value: rule.toValue(value),
@ -65,6 +86,9 @@ export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[]
? { technicianAssigned: patch.technicianAssigned }
: {}),
});
if (reassignmentAcrossType && rule.key === "type") {
ops.push({ field: "serviceId", value: String(patch.serviceId) });
}
}
return ops;

View file

@ -82,12 +82,19 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
* Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
*/
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
export function expandTablePatchToBoardFieldOps(
patch: WorkOrderTablePatch,
currentRow?: Pick<WorkOrderTableRow, "serviceId" | "type">,
): BoardOp[] {
if (isLocalOnlyTablePatch(patch)) {
return [];
}
return [...expandScheduleBoardOps(patch), ...expandPocOp(patch), ...expandScalarBoardOps(patch)];
return [
...expandScheduleBoardOps(patch),
...expandPocOp(patch),
...expandScalarBoardOps(patch, currentRow),
];
}
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {

View file

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

View file

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

View file

@ -83,12 +83,12 @@ async function applyBoardFieldOps(
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<WorkOrderTableRow> {
const ops = expandTablePatchToBoardFieldOps(patch);
const current = getRow?.(id);
const ops = expandTablePatchToBoardFieldOps(patch, current);
if (ops.length === 0) {
throw new BoardPatchValidationError("NoPersistableFields", NO_PERSISTABLE_FIELDS_MESSAGE);
}
const current = getRow?.(id);
if (!current?.rowVersion) {
throw new Error("Missing work order version for board patch. Refresh and retry.");
}

View file

@ -1,4 +1,5 @@
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { Service } from "@/domain/services/types/service";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
@ -12,6 +13,7 @@ import { addDaysIso } from "@/domain/work-orders/utils/wizard-date-utils";
type AdvancedFilterPredicate = (
row: WorkOrderTableRow,
filters: WorkOrderAdvancedFilters,
serviceHistory: Service[],
) => boolean;
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
@ -60,8 +62,15 @@ const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
const matchesStatus: AdvancedFilterPredicate = (row, filters) =>
!filters.statuses.size || (!!row.status && filters.statuses.has(row.status as never));
const matchesPmType: AdvancedFilterPredicate = (row, filters) =>
!filters.pmTypes.size || filters.pmTypes.has(row.pm);
const matchesPmType: AdvancedFilterPredicate = (row, filters, serviceHistory) => {
if (!filters.pmTypes.size || filters.pmTypes.has(row.pm)) return true;
if (row.serviceId == null) return false;
const linkedService = serviceHistory.find(
(service) => String(service.id) === String(row.serviceId),
);
return linkedService != null && filters.pmTypes.has(linkedService.name);
};
const matchesVendorTech: AdvancedFilterPredicate = (row, filters) =>
!filters.vendorTechs.size ||

View file

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

View file

@ -15,6 +15,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"woNumber",
"site",
"type",
"severity",
"dispatcherId",
"dispatcherName",
"scheduledOn",
@ -26,6 +27,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"completedDate",
"apptTime",
"pm",
"serviceId",
"serviceNotes",
"extraServices",
"avetaRequired",

View file

@ -1,4 +1,5 @@
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { Service } from "@/domain/services/types/service";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
@ -36,8 +37,9 @@ export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
export function matchesAdvancedFilters(
row: WorkOrderTableRow,
filters: WorkOrderAdvancedFilters,
serviceHistory: Service[] = [],
): boolean {
return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters));
return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters, serviceHistory));
}
function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {

View file

@ -1,10 +1,408 @@
import { fireEvent, screen } from "@testing-library/react";
import { useState } from "react";
import { fireEvent, screen, waitForElementToBeRemoved } 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 type { ServiceNotesDetails } 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 type { Service } from "@/domain/services/types/service";
import { renderWithProviders } from "@/test/test-utils";
function serviceDefinition(id: number, name: string): Service {
return {
id,
name,
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
};
}
function ServiceRegistryRefreshHarness({
withDetails,
onSaved,
}: {
withDetails: boolean;
onSaved: (details: ServiceNotesDetails) => void;
}) {
const [renamed, setRenamed] = useState(false);
const [saved, setSaved] = useState<ServiceNotesDetails>({
pm: "Original service",
serviceId: 701,
extraServices: [],
serviceNotes: withDetails ? "Existing notes" : "",
avetaRequired: false,
});
const primaryName = renamed ? "Renamed service" : "Original service";
return (
<>
<PMTypeCell
value={saved.pm}
pmTypes={[]}
services={[serviceDefinition(701, primaryName), serviceDefinition(702, "Extra service")]}
serviceId={saved.serviceId}
workOrderType="PM"
serviceNotes={saved.serviceNotes}
extraServices={saved.extraServices}
avetaRequired={saved.avetaRequired}
woNumber="WO-701"
serviceQuery={{
isLoading: false,
isError: !renamed,
isFetching: false,
hasData: true,
retry: () => setRenamed(true),
}}
onSave={vi.fn()}
onSaveDetails={(details) => {
onSaved(details);
setSaved(details);
}}
/>
</>
);
}
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
value="Saved service"
pmTypes={["Leak"]}
services={[]}
serviceQuery={{
isLoading: true,
isError: false,
isFetching: true,
hasData: false,
retry: vi.fn(),
}}
onSave={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
expect(screen.getByRole("status", { name: /loading services/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
});
it("offers a working retry and no legacy choices after registry failure", () => {
const onRetryServices = vi.fn();
renderWithProviders(
<PMTypeCell
value="Saved service"
pmTypes={["Leak"]}
services={[]}
serviceQuery={{
isLoading: false,
isError: true,
isFetching: false,
hasData: false,
retry: onRetryServices,
}}
onSave={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Saved service/i }));
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeInTheDocument();
expect(screen.getByText(/service options couldn't load/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetryServices).toHaveBeenCalledOnce();
});
it("shows a successful empty registry without legacy choices", () => {
renderWithProviders(<PMTypeCell value="" pmTypes={["Leak"]} services={[]} onSave={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /Select/i }));
expect(screen.getByText(/no services are available/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
});
it("keeps loaded options available while the registry refreshes", () => {
const onSave = vi.fn();
renderWithProviders(
<PMTypeCell
value="Old snapshot"
pmTypes={["Leak"]}
services={[
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
]}
serviceQuery={{
isLoading: false,
isError: false,
isFetching: true,
hasData: true,
retry: vi.fn(),
}}
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i }));
fireEvent.click(screen.getByRole("button", { name: "QA Test Service" }));
expect(onSave).toHaveBeenCalledWith("QA Test Service", 701);
});
it("shows eligible registry services and saves the selected service identity", () => {
const onSave = vi.fn();
renderWithProviders(
<PMTypeCell
value="Old snapshot"
serviceId={null}
pmTypes={["Leak", "No Power", "Not Cooling", "Other"]}
services={[
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
{
id: 702,
name: "Reactive Only",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["Reactive"],
},
]}
workOrderType="PM"
onSave={onSave}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Old snapshot/i }));
expect(screen.getByRole("button", { name: "QA Test Service" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reactive Only" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "QA Test Service" }));
expect(onSave).toHaveBeenCalledWith("QA Test Service", 701);
});
it("keeps the historical label when its linked registry service is inactive", () => {
renderWithProviders(
<PMTypeCell
value="Historic Service Name"
serviceId={701}
pmTypes={[]}
services={[
{
id: 701,
name: "Renamed Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
]}
workOrderType="PM"
onSave={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /Historic Service Name/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Renamed Service/i })).not.toBeInTheDocument();
});
it("keeps the completed work-order label when its active service is renamed", () => {
renderWithProviders(
<PMTypeCell
value="Saved Completed Label"
serviceId={701}
pmTypes={[]}
services={[
{
id: 701,
name: "Current Active Name",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
]}
workOrderType="PM"
viewOnly
onSave={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /Saved Completed Label/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Current Active Name/i })).not.toBeInTheDocument();
});
it("preserves the historic identity when only service notes change", () => {
const onSaveDetails = vi.fn();
renderWithProviders(
<PMTypeCell
value="Historic Service Name"
serviceId={701}
pmTypes={[]}
serviceNotes=""
services={[
{
id: 701,
name: "Renamed Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
]}
workOrderType="PM"
onSave={vi.fn()}
onSaveDetails={onSaveDetails}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Historic Service Name/i }));
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
fireEvent.change(
screen.getByPlaceholderText(
"Describe the specific issue, location details, door number, etc.",
),
{ target: { value: "Changed detail only" } },
);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
const saved = onSaveDetails.mock.calls[0]![0];
expect(saved).toMatchObject({ pm: "Historic Service Name", serviceId: 701 });
expect(serviceIdentityPatch(701, "Historic Service Name", saved.pm, saved.serviceId)).toEqual(
{},
);
});
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
@ -63,4 +461,342 @@ 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 }),
);
});
it.each([
["picker Add service and notes", false],
["direct details trigger", true],
])("retains the draft and linked ID across a rename from the %s", async (_entry, withDetails) => {
const onSaved = vi.fn();
renderWithProviders(
<ServiceRegistryRefreshHarness withDetails={withDetails} onSaved={onSaved} />,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /Original service/i }));
if (!withDetails) {
fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i }));
}
const notes = screen.getByPlaceholderText(/describe the specific issue/i);
fireEvent.change(notes, { target: { value: "Typed while registry refreshed" } });
fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i }));
fireEvent.click(screen.getByRole("button", { name: "Extra service" }));
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(notes).toHaveValue("Typed while registry refreshed");
expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked();
expect(screen.getByRole("button", { name: /Renamed servicePrimary/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSaved).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
pm: "Renamed service",
serviceId: 701,
extraServices: ["Extra service"],
serviceNotes: "Typed while registry refreshed",
avetaRequired: true,
}),
);
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /Renamed service/i }));
expect(screen.getByPlaceholderText(/describe the specific issue/i)).toHaveValue(
"Typed while registry refreshed",
);
expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked();
expect(screen.getByText("Primary").closest("button")).toHaveTextContent("Renamed service");
expect(screen.getByRole("button", { name: "Extra service" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSaved).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
serviceId: 701,
extraServices: ["Extra service"],
serviceNotes: "Typed while registry refreshed",
avetaRequired: true,
}),
);
});
it("preserves a deliberate primary selection during linked-service rename", () => {
const onSave = vi.fn();
const common = {
open: true,
onOpenChange: vi.fn(),
extraServices: [],
serviceNotes: "Saved notes",
avetaRequired: false,
pmTypes: [],
services: [
serviceDefinition(701, "Original service"),
serviceDefinition(702, "Other service"),
],
serviceId: 701,
serviceQuery: {
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
},
onSave,
};
const view = renderWithProviders(<ServiceNotesDialog {...common} pm="Original service" />, {
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: /Original servicePrimary/i }));
fireEvent.click(screen.getByRole("button", { name: "Other service" }));
view.rerender(
<ServiceNotesDialog
{...common}
pm="Renamed service"
services={[
serviceDefinition(701, "Renamed service"),
serviceDefinition(702, "Other service"),
]}
/>,
);
expect(screen.getByRole("button", { name: /Other servicePrimary/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ pm: "Other service", serviceId: 702 }),
);
});
it("keeps the chosen identity when the saved same-label service is renamed", () => {
const onSave = vi.fn();
const common = {
open: true,
onOpenChange: vi.fn(),
extraServices: [],
serviceNotes: "Saved notes",
avetaRequired: false,
pmTypes: [],
serviceQuery: {
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
},
onSave,
};
const view = renderWithProviders(
<ServiceNotesDialog
{...common}
pm="Retired Service"
serviceId={1}
services={[serviceDefinition(2, "Retired Service"), serviceDefinition(3, "Extra Service")]}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /Retired ServicePrimary/i }));
fireEvent.change(screen.getByPlaceholderText(/describe the specific issue/i), {
target: { value: "Draft note" },
});
fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i }));
fireEvent.click(screen.getByRole("button", { name: "Extra Service" }));
view.rerender(
<ServiceNotesDialog
{...common}
pm="Renamed historical service"
serviceId={1}
services={[serviceDefinition(2, "Retired Service"), serviceDefinition(3, "Extra Service")]}
/>,
);
expect(screen.getByRole("button", { name: /Retired ServicePrimary/i })).toBeInTheDocument();
view.rerender(
<ServiceNotesDialog
{...common}
pm="Renamed historical service"
serviceId={1}
services={[
serviceDefinition(2, "Renamed chosen service"),
serviceDefinition(3, "Extra Service"),
]}
/>,
);
expect(
screen.getByRole("button", { name: /Renamed chosen servicePrimary/i }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
pm: "Renamed chosen service",
serviceId: 2,
extraServices: ["Extra Service"],
serviceNotes: "Draft note",
avetaRequired: true,
}),
);
});
});

View file

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

View file

@ -7,6 +7,7 @@ vi.mock("react-toastify", () => ({
}));
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
import type { Service } from "@/domain/services/types/service";
const row = { id: 7, woNumber: "00000024965", severity: 3 as const };
@ -18,7 +19,7 @@ describe("saveExistingRowType", () => {
it("warns when severity newly enters SEV 1 only after persist onSuccess", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, row, "Emergency", 1);
saveExistingRowType({ onPatch, row, type: "Emergency", severity: 1 });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(
@ -35,7 +36,7 @@ describe("saveExistingRowType", () => {
it("does not warn when staying on a critical severity", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1);
saveExistingRowType({ onPatch, row: { ...row, severity: 1 }, type: "Emergency", severity: 1 });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 });
@ -45,7 +46,7 @@ describe("saveExistingRowType", () => {
it("does not warn when moving between SEV 3–5", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3);
saveExistingRowType({ onPatch, row: { ...row, severity: 5 }, type: "Reactive", severity: 3 });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 });
@ -55,10 +56,156 @@ describe("saveExistingRowType", () => {
it("does not warn when clearing severity on a PM correction", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null);
saveExistingRowType({ onPatch, row: { ...row, severity: 2 }, type: "PM", severity: null });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
});
it("clears an active service before changing to an unsupported work-order type", () => {
const onPatch = vi.fn();
const services: Service[] = [
{
id: 701,
name: "PM Only Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
];
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 701 },
type: "Emergency",
severity: null,
serviceHistory: services,
});
expect(onPatch).toHaveBeenCalledWith(7, {
type: "Emergency",
severity: null,
serviceId: null,
pm: "",
});
});
it("preserves a known inactive historical service that supports the new type", () => {
const onPatch = vi.fn();
const services: Service[] = [
{
id: 702,
name: "Retired Multi-type Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM", "Reactive"],
},
];
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 702 },
type: "Reactive",
severity: null,
serviceHistory: services,
});
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null });
});
it("clears a known inactive historical service before type change", () => {
const onPatch = vi.fn();
const services: Service[] = [
{
id: 701,
name: "Retired Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
];
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 701 },
type: "Emergency",
severity: null,
serviceHistory: services,
});
expect(onPatch).toHaveBeenCalledWith(7, {
type: "Emergency",
severity: null,
serviceId: null,
pm: "",
});
});
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 = {
id: 701,
name: "Retired Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
};
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 701 },
type: "Emergency",
severity: null,
serviceHistory: [inactiveService],
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 });
});
});

View file

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

View file

@ -1,6 +1,8 @@
import { useState } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
import { SlideOverInfoTabDisplayView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-display-view";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
import { buildSlideOverPatch } from "@/domain/work-orders/utils/slide-over-draft";
@ -48,7 +50,20 @@ vi.mock(
}),
);
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
TypeCell: () => null,
TypeCell: ({
onSave,
}: {
onSave: (type: "Reactive" | "Emergency", severity: number) => void;
}) => (
<>
<button type="button" onClick={() => onSave("Reactive", 2)}>
Change type to Reactive
</button>
<button type="button" onClick={() => onSave("Emergency", 2)}>
Change type to Emergency
</button>
</>
),
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
AssignedToCell: () => null,
@ -59,9 +74,6 @@ vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", (
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
ApptTimeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
PMTypeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
VendorCell: () => null,
}));
@ -121,6 +133,219 @@ function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow
}
describe("SlideOverInfoTabEditView additional contacts", () => {
it("keeps the historic service label in slide-over display for an inactive service", () => {
renderWithProviders(
<SlideOverInfoTabDisplayView
source={baseRow({ pm: "Historic Service Name", serviceId: 701 })}
isCancelled={false}
sites={[]}
vendors={[]}
pmTypes={[]}
services={[
{
id: 701,
name: "Renamed Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
]}
/>,
{ withAuth: false },
);
expect(screen.getByText("Historic Service Name")).toBeInTheDocument();
expect(screen.queryByText("Renamed Service")).not.toBeInTheDocument();
});
it("keeps a completed slide-over's saved label after its active service is renamed", () => {
renderWithProviders(
<SlideOverInfoTabDisplayView
source={baseRow({
status: "Completed",
pm: "Saved Completed Label",
serviceId: 701,
})}
isCancelled={false}
isCompleted
sites={[]}
vendors={[]}
pmTypes={[]}
services={[
{
id: 701,
name: "Current Active Name",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
]}
/>,
{ withAuth: false },
);
expect(screen.getByText("Saved Completed Label")).toBeInTheDocument();
expect(screen.queryByText("Current Active Name")).not.toBeInTheDocument();
});
it("saves an incompatible type change only after clearing the registry service", () => {
const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 });
const onSave = vi.fn();
const inactivePmOnlyService = {
id: 701,
name: "PM only",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM" as const],
};
function SlideOverSaveHarness() {
const [draft, setDraft] = useState(baseline);
return (
<>
<SlideOverInfoTabEditView
draft={draft}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
services={[]}
serviceHistory={[inactivePmOnlyService]}
allOrders={[]}
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
/>
<button
type="button"
onClick={() =>
onSave(expandTablePatchToBoardFieldOps(buildSlideOverPatch(draft, baseline)))
}
>
Save changes
</button>
</>
);
}
renderWithProviders(<SlideOverSaveHarness />, { withAuth: false });
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: "serviceId", value: "" },
{ field: "workOrderType", value: "Emergency" },
]);
});
it("preserves a compatible inactive service during a slide-over type change", () => {
const baseline = baseRow({ type: "PM", pm: "Historical service", serviceId: 702 });
const onSave = vi.fn();
const compatibleInactiveService = {
id: 702,
name: "Historical service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM" as const, "Reactive" as const],
};
function SlideOverSaveHarness() {
const [draft, setDraft] = useState(baseline);
return (
<>
<SlideOverInfoTabEditView
draft={draft}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
services={[]}
serviceHistory={[compatibleInactiveService]}
allOrders={[]}
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
/>
<button
type="button"
onClick={() =>
onSave(expandTablePatchToBoardFieldOps(buildSlideOverPatch(draft, baseline)))
}
>
Save changes
</button>
</>
);
}
renderWithProviders(<SlideOverSaveHarness />, { withAuth: false });
fireEvent.click(screen.getByRole("button", { name: "Change type to Reactive" }));
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onSave).toHaveBeenCalledWith([
{ field: "severity", value: "2" },
{ field: "workOrderType", value: "Reactive" },
]);
});
it("does not clear a saved service while registry history is unresolved", () => {
const baseline = baseRow({ type: "PM", pm: "Historical service", serviceId: 703 });
const onSave = vi.fn();
const pmOnlyService = {
id: 703,
name: "Historical service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM" as const],
};
function SlideOverSaveHarness() {
const [draft, setDraft] = useState(baseline);
return (
<>
<SlideOverInfoTabEditView
draft={draft}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
services={[]}
serviceHistory={[pmOnlyService]}
serviceHistoryReady={false}
allOrders={[]}
onDraftChange={(patch) => setDraft((current) => ({ ...current, ...patch }))}
/>
<button
type="button"
onClick={() =>
onSave(expandTablePatchToBoardFieldOps(buildSlideOverPatch(draft, baseline)))
}
>
Save changes
</button>
</>
);
}
renderWithProviders(<SlideOverSaveHarness />, { withAuth: false });
fireEvent.click(screen.getByRole("button", { name: "Change type to Emergency" }));
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
expect(onSave).toHaveBeenCalledWith([]);
});
it("passes additionalContacts into SiteCell and persists them on save", () => {
const onDraftChange = vi.fn();
const contacts = [{ name: "Existing", phone: "555-0000" }];

View file

@ -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);
});
});

View file

@ -52,6 +52,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () =>
}),
}));
vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({
useWorkOrderServiceRegistry: () => ({
services: [],
serviceHistory: [],
servicePickerState: {
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
},
serviceHistoryState: {
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
},
serviceHistoryReadyForTypeChange: true,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
useWorkOrderTableMutations: () => ({
patchField,

View file

@ -51,6 +51,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () =>
}),
}));
vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({
useWorkOrderServiceRegistry: () => ({
services: [],
serviceHistory: [],
servicePickerState: {
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
},
serviceHistoryState: {
isLoading: false,
isError: false,
isFetching: false,
hasData: true,
retry: vi.fn(),
},
serviceHistoryReadyForTypeChange: true,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
useWorkOrderTableMutations: () => ({
patchField,

View file

@ -128,6 +128,28 @@ function tableData(
}
describe("WoTable advanced-search pagination", () => {
it("renders the nonempty table body and pager", () => {
renderWithProviders(
<WoTable
tableData={tableData()}
users={[]}
sites={[]}
vendors={[]}
pmTypes={[]}
weekMonday="2026-05-18"
onNavigateToDay={vi.fn()}
onOpenSlideOver={vi.fn()}
onOpenMedia={vi.fn()}
onPatch={vi.fn()}
/>,
{ withAuth: false },
);
expect(screen.getByText("WO-0")).toBeInTheDocument();
expect(screen.getByText("RESULTS · 1778 items")).toBeInTheDocument();
expect(screen.getByText("Page 1 of 36")).toBeInTheDocument();
});
it("pages the flat Unassigned search without a pin or a 1778-row dump", () => {
const goToNextDayPage = vi.fn();
const data = tableData({ goToNextDayPage });

View file

@ -12,6 +12,48 @@ function currentMonthIso(day: number): string {
}
describe("WoTableInlineRowCells — Schedule On range", () => {
it("keeps registry failure and retry in the inline Add WO service picker", async () => {
const retry = vi.fn();
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableInlineRowCells
draft={EMPTY_TABLE_DRAFT}
weekDefault={false}
users={[]}
sites={[]}
vendors={[]}
pmTypes={["Leak"]}
services={[]}
serviceQuery={{
isLoading: false,
isError: true,
isFetching: false,
hasData: false,
retry,
}}
allRows={[]}
siteCellRef={createRef()}
woCellRef={createRef()}
siteShowError={false}
siteError=""
woShowError={false}
onPatch={vi.fn()}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getByRole("button", { name: "Select …" }));
expect(screen.getByRole("alert", { name: /service options unavailable/i })).toBeVisible();
expect(screen.queryByRole("button", { name: "Leak" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(retry).toHaveBeenCalledOnce();
});
it("reopens a company-only inline assignment without inferring its anchor as technician", async () => {
renderWithProviders(
<table>

View file

@ -11,6 +11,22 @@ import { renderWithProviders } from "@/test/test-utils";
const setAdvOpen = vi.fn();
const applyAdvancedFilters = vi.fn();
const clearAdvancedFilters = vi.fn();
const serviceQueries = vi.hoisted(() => ({
active: {
data: undefined as unknown,
isPending: false as boolean,
isError: false as boolean,
isFetching: false as boolean,
refetch: vi.fn(),
},
all: {
data: undefined as unknown,
isPending: false as boolean,
isError: false as boolean,
isFetching: false as boolean,
refetch: vi.fn(),
},
}));
let advOpen = false;
let advApplied: typeof EMPTY_ADVANCED_FILTERS | null = null;
@ -102,9 +118,29 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
}));
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
useDropdownOptionsByCategory: () => ({ data: [{ value: "HVAC" }] }),
useDropdownOptionsByCategory: () => ({
data: [
{ value: "HVAC" },
{ value: "Leak" },
{ value: "No Power" },
{ value: "Not Cooling" },
{ value: "Other" },
],
}),
}));
vi.mock("@/domain/services/use-cases/use-services-for-work-order-type", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/domain/services/use-cases/use-services-for-work-order-type")
>();
return {
...actual,
useActiveServices: () => serviceQueries.active,
useAllServices: () => serviceQueries.all,
};
});
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
isWoTableMockEnabled: () => false,
}));
@ -121,11 +157,157 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
beforeEach(() => {
advOpen = false;
advApplied = null;
serviceQueries.active.data = [
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
},
];
serviceQueries.active.isPending = false;
serviceQueries.active.isError = false;
serviceQueries.active.isFetching = false;
serviceQueries.active.refetch.mockReset();
serviceQueries.all.data = [
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
},
{
id: 702,
name: "Retired Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM"],
},
];
serviceQueries.all.isPending = false;
serviceQueries.all.isError = false;
serviceQueries.all.isFetching = false;
serviceQueries.all.refetch.mockReset();
setAdvOpen.mockClear();
applyAdvancedFilters.mockClear();
clearAdvancedFilters.mockClear();
});
it("offers active registry services instead of legacy Problem dropdown values", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
expect(within(sheet).getByText("Retired Service")).toBeInTheDocument();
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
});
it("shows loading instead of legacy values while registry history is pending", () => {
serviceQueries.all.data = undefined;
serviceQueries.all.isPending = true;
serviceQueries.all.isFetching = true;
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
expect(within(sheet).getByRole("status", { name: /loading services/i })).toBeInTheDocument();
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
expect(within(sheet).getByPlaceholderText("Search service…")).toBeDisabled();
});
it("shows a retryable registry error and no legacy service filters on failure", () => {
serviceQueries.all.data = undefined;
serviceQueries.all.isError = true;
advApplied = {
...EMPTY_ADVANCED_FILTERS,
pmTypes: new Set(["Previously selected service"]),
};
serviceQueries.all.refetch.mockImplementation(() => {
serviceQueries.all.data = [
{
id: 701,
name: "QA Test Service",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM"],
},
];
serviceQueries.all.isError = false;
serviceQueries.all.isPending = false;
serviceQueries.all.isFetching = false;
});
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
expect(
within(sheet).getByRole("alert", { name: /service filters unavailable/i }),
).toBeInTheDocument();
expect(within(sheet).getByText(/service filters couldn't load/i)).toBeInTheDocument();
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument();
expect(within(sheet).getByPlaceholderText("Search service…")).toBeDisabled();
fireEvent.click(within(sheet).getByRole("button", { name: "Retry" }));
expect(serviceQueries.all.refetch).toHaveBeenCalledOnce();
view.rerender(<WorkOrdersListPage />);
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
expect(within(sheet).getByText("Previously selected service")).toBeInTheDocument();
});
it("shows a successful empty registry as empty instead of legacy choices", () => {
serviceQueries.all.data = [];
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
withAuth: false,
});
fireEvent.click(screen.getByRole("button", { name: "Advanced Filters" }));
advOpen = true;
view.rerender(<WorkOrdersListPage />);
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
expect(within(sheet).getByText(/no services are available/i)).toBeInTheDocument();
expect(within(sheet).queryByText("Leak")).not.toBeInTheDocument();
});
it("opens the sheet from Advanced Filters, applies, then clears", () => {
const view = renderWithProviders(<WorkOrdersListPage />, {
route: "/workorders",
@ -149,7 +331,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
}
expect(within(sheet).getByText("Seattle")).toBeInTheDocument();
expect(within(sheet).getByText("Dispatcher One")).toBeInTheDocument();
expect(within(sheet).getByText("HVAC")).toBeInTheDocument();
expect(within(sheet).getByText("QA Test Service")).toBeInTheDocument();
expect(within(sheet).getByText("Vendor Co")).toBeInTheDocument();
fireEvent.click(within(sheet).getByRole("button", { name: /Aveta Compliance/i }));
@ -218,7 +400,7 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
const sheet = screen.getByRole("dialog", { name: "Advanced Filters" });
fireEvent.click(within(sheet).getByRole("button", { name: /^Service/i }));
fireEvent.click(within(sheet).getByText("HVAC"));
fireEvent.click(within(sheet).getByText("QA Test Service"));
fireEvent.click(within(sheet).getByRole("button", { name: /Completion doc/i }));
fireEvent.click(within(sheet).getByRole("button", { name: /^Pending$/i }));
@ -230,13 +412,13 @@ describe("WorkOrders Advanced Search page wiring (SH-121)", () => {
expect(applyAdvancedFilters).toHaveBeenCalled();
const appliedCalls = applyAdvancedFilters.mock.calls;
const applied = appliedCalls[appliedCalls.length - 1]?.[0] as typeof EMPTY_ADVANCED_FILTERS;
expect(applied.pmTypes.has("HVAC")).toBe(true);
expect(applied.pmTypes.has("QA Test Service")).toBe(true);
expect(applied.docs.has("No")).toBe(true);
expect(applied.docs.has("Yes")).toBe(true);
expect(applied.docs.has("NN")).toBe(true);
const params = toBoardSearchQueryParams({ filters: applied });
expect(params.pmTypes).toEqual(["HVAC"]);
expect(params.pmTypes).toEqual(["QA Test Service"]);
expect(params.docStatuses).toEqual([2, 1, 3]);
});

View file

@ -65,6 +65,19 @@ describe("expandTablePatchToBoardFieldOps", () => {
expect(expandTablePatchToBoardFieldOps({ pm: "HVAC" })).toEqual([
{ field: "pm", value: "HVAC" },
]);
expect(expandTablePatchToBoardFieldOps({ pm: "QA Test Service", serviceId: 701 })).toEqual([
{ field: "serviceId", value: "701" },
]);
expect(expandTablePatchToBoardFieldOps({ serviceId: null, pm: "" })).toEqual([
{ field: "serviceId", value: "" },
]);
expect(
expandTablePatchToBoardFieldOps({ severity: 2, serviceId: null, type: "Reactive", pm: "" }),
).toEqual([
{ field: "severity", value: "2" },
{ field: "serviceId", value: "" },
{ field: "workOrderType", value: "Reactive" },
]);
expect(expandTablePatchToBoardFieldOps({ docStatus: "NN" })).toEqual([
{ field: "docStatus", value: "NotNeeded" },
]);
@ -85,6 +98,32 @@ describe("expandTablePatchToBoardFieldOps", () => {
]);
});
it("clears the current service before changing type and assigns the new service afterward", () => {
expect(
expandTablePatchToBoardFieldOps(
{ severity: 2, type: "Emergency", serviceId: 702, pm: "Emergency Service" },
{ serviceId: 701, type: "PM" },
),
).toEqual([
{ field: "serviceId", value: "" },
{ field: "severity", value: "2" },
{ field: "workOrderType", value: "Emergency" },
{ field: "serviceId", value: "702" },
]);
});
it("keeps same-type service edits as a single assignment", () => {
expect(
expandTablePatchToBoardFieldOps(
{ type: "PM", serviceId: 702, pm: "PM Service B" },
{ serviceId: 701, type: "PM" },
),
).toEqual([
{ field: "serviceId", value: "702" },
{ field: "workOrderType", value: "PM" },
]);
});
it("emits service notes modal ops in pm → extraServices → serviceNotes order", () => {
expect(
expandTablePatchToBoardFieldOps({

View file

@ -783,6 +783,58 @@ describe("useWorkOrderTableMutations type and severity", () => {
);
});
it("chains clear, severity, type, and new service using each returned row version", async () => {
const calls: { field: string; value: string; version: string }[] = [];
rowState = { ...BASE_ROW, type: "PM", serviceId: 701, rowVersion: "wo-v1" };
patchBoardField.mockImplementation(
async (_id: unknown, payload: { field: string; value: string; workOrderVersion: string }) => {
calls.push({
field: payload.field,
value: payload.value,
version: payload.workOrderVersion,
});
rowState = {
...rowState,
...(payload.field === "serviceId"
? { serviceId: payload.value ? Number(payload.value) : null }
: {}),
...(payload.field === "workOrderType" ? { type: payload.value as "Emergency" } : {}),
...(payload.field === "severity" ? { severity: 2 as const } : {}),
rowVersion: `wo-v${calls.length + 1}`,
};
return { ...rowState };
},
);
const onPatch = vi.fn();
const { result } = renderHook(
() =>
useWorkOrderTableMutations({
onPatch,
clearPatch: () => undefined,
getRow: () => rowState,
}),
{ wrapper: makeWrapper() },
);
act(() => {
result.current.patchField(42, {
type: "Emergency",
severity: 2,
serviceId: 702,
pm: "Emergency Service",
});
});
await waitFor(() => expect(calls).toHaveLength(4));
expect(calls).toEqual([
{ field: "serviceId", value: "", version: "wo-v1" },
{ field: "severity", value: "2", version: "wo-v2" },
{ field: "workOrderType", value: "Emergency", version: "wo-v3" },
{ field: "serviceId", value: "702", version: "wo-v4" },
]);
});
it("applies currentState on 409 conflict instead of clearing", async () => {
const currentState = { ...BASE_ROW, type: "PM" as const, severity: null };
patchBoardField.mockRejectedValue(new BoardPatchConflictError("stale", currentState));

View file

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

View file

@ -7,6 +7,7 @@ import {
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
import { expandTablePatchToBoardFieldOps } from "@/domain/work-orders/mappers/work-order-board-patch-mapper";
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
@ -68,6 +69,7 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("technicianAssigned");
expect(SLIDE_OVER_EDIT_KEYS).toContain("additionalContacts");
expect(SLIDE_OVER_EDIT_KEYS).toContain("avetaRequired");
expect(SLIDE_OVER_EDIT_KEYS).toContain("serviceId");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocPhone");
expect(SLIDE_OVER_EDIT_KEYS).toContain("pocNotes");
@ -77,6 +79,20 @@ describe("slide-over-draft", () => {
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
});
it("saves an incompatible type change after clearing its registry service", () => {
const baseline = baseRow({ type: "PM", pm: "PM only", serviceId: 701 });
const draft = baseRow({ type: "Reactive", severity: 2, pm: "", serviceId: null });
const patch = buildSlideOverPatch(draft, baseline);
expect(isSlideOverDraftDirty(draft, baseline)).toBe(true);
expect(patch).toMatchObject({ type: "Reactive", severity: 2, serviceId: null, pm: "" });
expect(expandTablePatchToBoardFieldOps(patch)).toEqual([
{ field: "severity", value: "2" },
{ field: "serviceId", value: "" },
{ field: "workOrderType", value: "Reactive" },
]);
});
it("tracks vendor via vendorId only (company/tech are display companions)", () => {
const row = baseRow();
const draft = baseRow({ company: "Other Co", tech: "Pat" });

View file

@ -370,6 +370,42 @@ describe("buildDayGroups", () => {
});
describe("matchesAdvancedFilters", () => {
it("matches inactive renamed registry services across the global date range", () => {
const row = makeRow({
pm: "Historic Service Name",
serviceId: 701,
scheduledOn: "2024-03-12",
});
const filters = {
...EMPTY_ADVANCED_FILTERS,
pmTypes: new Set(["Current Service Name"]),
};
const serviceHistory = [
{
id: 701,
name: "Current Service Name",
trade: "Electrical",
iconKey: "wrench",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: false,
supportedWorkOrderTypes: ["PM" as const],
},
];
expect(matchesAdvancedFilters(row, filters, serviceHistory)).toBe(true);
expect(row.pm).toBe("Historic Service Name");
});
it("continues matching legacy service labels without a linked registry identity", () => {
const filters = {
...EMPTY_ADVANCED_FILTERS,
pmTypes: new Set(["Legacy Problem"]),
};
expect(matchesAdvancedFilters(makeRow({ pm: "Legacy Problem" }), filters, [])).toBe(true);
});
it("filters by site and type", () => {
const weekMonday = mondayOf(todayIso());
const row = makeRow({